@pikku/inspector 0.12.25 → 0.12.27
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 +102 -0
- package/dist/add/add-auth.js +11 -0
- package/dist/add/add-functions.js +6 -1
- package/dist/inspector.js +18 -3
- package/dist/types.d.ts +19 -0
- package/dist/utils/extract-function-name.js +20 -3
- package/dist/utils/schema-generator.d.ts +1 -0
- package/dist/utils/schema-generator.js +76 -0
- package/dist/utils/workflow/derive-workflow-plan.d.ts +20 -0
- package/dist/utils/workflow/derive-workflow-plan.js +78 -0
- package/dist/utils/workflow/graph/finalize-workflows.js +15 -0
- package/dist/utils/workflow/graph/workflow-graph.types.d.ts +14 -1
- package/dist/visit.d.ts +1 -0
- package/dist/visit.js +14 -1
- package/package.json +2 -2
- package/src/add/add-auth.test.ts +50 -0
- package/src/add/add-auth.ts +14 -0
- package/src/add/add-functions.ts +6 -1
- package/src/add/pii-check.test.ts +3 -1
- package/src/inspector.ts +24 -2
- package/src/types.ts +19 -0
- package/src/utils/extract-function-name.ts +21 -3
- package/src/utils/schema-generator.ts +92 -0
- package/src/utils/workflow/derive-workflow-plan.test.ts +122 -0
- package/src/utils/workflow/derive-workflow-plan.ts +90 -0
- package/src/utils/workflow/graph/finalize-workflows.ts +15 -0
- package/src/utils/workflow/graph/workflow-graph.types.ts +18 -1
- package/src/visit.ts +25 -1
- package/tsconfig.tsbuildinfo +1 -1
package/src/add/add-auth.ts
CHANGED
|
@@ -168,6 +168,20 @@ export const addAuth: AddWiring = (logger, node, _checker, state) => {
|
|
|
168
168
|
state.auth.userStatelessSession = true
|
|
169
169
|
}
|
|
170
170
|
|
|
171
|
+
// Same rule for the stateful variant: a user-registered global
|
|
172
|
+
// betterAuthSession(...) (custom mapSession/impersonation/apiKey) means the CLI
|
|
173
|
+
// must NOT auto-generate its own default one — the generated one runs first and
|
|
174
|
+
// pre-empts the user's via the `if (session) next()` short-circuit. Stateful
|
|
175
|
+
// analogue of the betterAuthStatelessSession skip above.
|
|
176
|
+
if (
|
|
177
|
+
ts.isIdentifier(expression) &&
|
|
178
|
+
expression.text === 'betterAuthSession' &&
|
|
179
|
+
!node.getSourceFile().fileName.endsWith('.gen.ts') &&
|
|
180
|
+
isInsideGlobalMiddlewareRegistration(node)
|
|
181
|
+
) {
|
|
182
|
+
state.auth.hasUserSessionMiddleware = true
|
|
183
|
+
}
|
|
184
|
+
|
|
171
185
|
if (!ts.isIdentifier(expression) || expression.text !== 'pikkuBetterAuth')
|
|
172
186
|
return
|
|
173
187
|
|
package/src/add/add-functions.ts
CHANGED
|
@@ -913,7 +913,12 @@ export const addFunctions: AddWiring = (
|
|
|
913
913
|
// secret → never returned by any exposed function (sessioned or not)
|
|
914
914
|
// private → only visible to authenticated (sessioned) users; ok for pikkuFunc
|
|
915
915
|
// public → safe for sessionless functions
|
|
916
|
-
|
|
916
|
+
// Opt-in only: inferring every handler's return type (getReturnTypeOfSignature)
|
|
917
|
+
// is the single most expensive checker operation and dominates `pikku all`
|
|
918
|
+
// wall-clock. The classification leak scan is a security lint, not codegen, so
|
|
919
|
+
// it runs ONLY when explicitly requested (`pikku all --security`) — see the
|
|
920
|
+
// classificationCheck option. Default codegen skips it entirely.
|
|
921
|
+
if (options.classificationCheck) {
|
|
917
922
|
const sig = checker.getSignatureFromDeclaration(handler)
|
|
918
923
|
if (sig) {
|
|
919
924
|
const rawRet = checker.getReturnTypeOfSignature(sig)
|
|
@@ -48,7 +48,9 @@ async function runInspect(sourceCode: string) {
|
|
|
48
48
|
await writeFile(file, sourceCode)
|
|
49
49
|
const { logger, criticals } = makeLogger()
|
|
50
50
|
try {
|
|
51
|
-
|
|
51
|
+
// The data-classification leak scan is opt-in (off by default to keep it
|
|
52
|
+
// off the `pikku all` hot path); these tests exercise it, so enable it.
|
|
53
|
+
await inspect(logger, [file], { rootDir: tmpDir, classificationCheck: true })
|
|
52
54
|
} finally {
|
|
53
55
|
await rm(tmpDir, { recursive: true, force: true })
|
|
54
56
|
}
|
package/src/inspector.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import * as ts from 'typescript'
|
|
2
2
|
import { performance } from 'perf_hooks'
|
|
3
3
|
import { resolve } from 'path'
|
|
4
|
-
import { visitSetup, visitRoutes } from './visit.js'
|
|
4
|
+
import { visitSetup, visitFunctions, visitRoutes } from './visit.js'
|
|
5
5
|
import { TypesMap } from './types-map.js'
|
|
6
6
|
import type {
|
|
7
7
|
InspectorState,
|
|
@@ -269,6 +269,12 @@ export const inspect = async (
|
|
|
269
269
|
// node_modules under rootDir (e.g. a locally-installed addon) is a
|
|
270
270
|
// dependency, not project source — scanning it double-counts the addon's
|
|
271
271
|
// own application types (CoreConfig/Services/SingletonServices).
|
|
272
|
+
// Sort by file name so the sweeps populate state in a stable order. The
|
|
273
|
+
// program's own file order depends on glob + import-graph resolution, which
|
|
274
|
+
// varies run to run — leaving generated meta keys (and anything serialized
|
|
275
|
+
// in insertion order) non-reproducible across identical `pikku all` runs.
|
|
276
|
+
// Safe because function registration is a dedicated pass (visitFunctions)
|
|
277
|
+
// that completes before any order-sensitive wiring resolution in visitRoutes.
|
|
272
278
|
const sourceFiles = program
|
|
273
279
|
.getSourceFiles()
|
|
274
280
|
.filter(
|
|
@@ -276,6 +282,9 @@ export const inspect = async (
|
|
|
276
282
|
sf.fileName.startsWith(rootDir) &&
|
|
277
283
|
!sf.fileName.includes('/node_modules/')
|
|
278
284
|
)
|
|
285
|
+
.sort((a, b) =>
|
|
286
|
+
a.fileName < b.fileName ? -1 : a.fileName > b.fileName ? 1 : 0
|
|
287
|
+
)
|
|
279
288
|
logger.debug(
|
|
280
289
|
`Got source files in ${(performance.now() - startSourceFiles).toFixed(2)}ms`
|
|
281
290
|
)
|
|
@@ -298,7 +307,20 @@ export const inspect = async (
|
|
|
298
307
|
await loadAddonFunctionsMeta(logger, state)
|
|
299
308
|
|
|
300
309
|
if (!options.setupOnly) {
|
|
301
|
-
//
|
|
310
|
+
// Function sweep: register every function before transports/wirings resolve
|
|
311
|
+
// them, so resolution doesn't depend on source-file order.
|
|
312
|
+
const startFunctions = performance.now()
|
|
313
|
+
for (const sourceFile of sourceFiles) {
|
|
314
|
+
const sourceOptions = { ...options, sourceFile }
|
|
315
|
+
ts.forEachChild(sourceFile, (child) =>
|
|
316
|
+
visitFunctions(logger, checker, child, state, sourceOptions)
|
|
317
|
+
)
|
|
318
|
+
}
|
|
319
|
+
logger.debug(
|
|
320
|
+
`Visit functions phase completed in ${(performance.now() - startFunctions).toFixed(0)}ms`
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
// Transport sweep: add all transports/wirings
|
|
302
324
|
const startRoutes = performance.now()
|
|
303
325
|
for (const sourceFile of sourceFiles) {
|
|
304
326
|
const sourceOptions = { ...options, sourceFile }
|
package/src/types.ts
CHANGED
|
@@ -287,6 +287,13 @@ export type InspectorOptions = Partial<{
|
|
|
287
287
|
tsconfig: string
|
|
288
288
|
schemasFromTypes?: string[]
|
|
289
289
|
schema?: { additionalProperties?: boolean }
|
|
290
|
+
/**
|
|
291
|
+
* Directory for the on-disk TS-schema cache. When set, generated TS schemas
|
|
292
|
+
* are persisted here keyed by a hash of the custom-types content, so a warm
|
|
293
|
+
* `pikku all` whose function types are unchanged skips ts-json-schema-generator
|
|
294
|
+
* entirely (the single largest cold-run cost). Omit to disable disk caching.
|
|
295
|
+
*/
|
|
296
|
+
cacheDir?: string
|
|
290
297
|
}
|
|
291
298
|
openAPI: {
|
|
292
299
|
additionalInfo: OpenAPISpecInfo
|
|
@@ -294,6 +301,12 @@ export type InspectorOptions = Partial<{
|
|
|
294
301
|
tags: string[]
|
|
295
302
|
manifest: VersionManifest
|
|
296
303
|
oldProgram: ts.Program | undefined
|
|
304
|
+
/**
|
|
305
|
+
* Run the data-classification leak scan (Private/Pii/Secret brands in function
|
|
306
|
+
* return types). Off by default — it forces return-type inference on every
|
|
307
|
+
* function, which is expensive. Enabled via `pikku all --security`.
|
|
308
|
+
*/
|
|
309
|
+
classificationCheck: boolean
|
|
297
310
|
}>
|
|
298
311
|
|
|
299
312
|
export interface InspectorLogger {
|
|
@@ -502,6 +515,12 @@ export interface InspectorState {
|
|
|
502
515
|
* own default-map stateless middleware, which would otherwise pre-empt the
|
|
503
516
|
* user's custom mapSession (pikkujs/pikku#754). */
|
|
504
517
|
userStatelessSession?: boolean
|
|
518
|
+
/** True when a user (non-generated) file already registers a global
|
|
519
|
+
* `betterAuthSession(...)`. The CLI then skips auto-generating its own
|
|
520
|
+
* default stateful middleware, which would otherwise run first and pre-empt
|
|
521
|
+
* the user's config (mapSession/impersonation/apiKey). Stateful analogue of
|
|
522
|
+
* `userStatelessSession`. */
|
|
523
|
+
hasUserSessionMiddleware?: boolean
|
|
505
524
|
}
|
|
506
525
|
secrets: {
|
|
507
526
|
definitions: SecretDefinitions
|
|
@@ -1,7 +1,25 @@
|
|
|
1
1
|
import * as ts from 'typescript'
|
|
2
|
-
import {
|
|
2
|
+
import { createHash } from 'crypto'
|
|
3
|
+
import { relative } from 'path'
|
|
3
4
|
import { formatVersionedId } from '@pikku/core'
|
|
4
5
|
|
|
6
|
+
/**
|
|
7
|
+
* Deterministic placeholder id for an anonymous/unnamed pikku function or
|
|
8
|
+
* permission. Derived from the call expression's source location (relative path
|
|
9
|
+
* + start position) so `pikku all` produces byte-identical output across runs —
|
|
10
|
+
* a `randomUUID()` here made generated meta non-reproducible. Still `__temp_`
|
|
11
|
+
* prefixed so downstream resolution (which keys off that prefix) is unchanged.
|
|
12
|
+
*/
|
|
13
|
+
function tempFuncId(callExpr: ts.Node, rootDir: string): string {
|
|
14
|
+
const sourceFile = callExpr.getSourceFile()
|
|
15
|
+
const relPath = relative(rootDir, sourceFile.fileName)
|
|
16
|
+
const hash = createHash('sha1')
|
|
17
|
+
.update(`${relPath}:${callExpr.getStart()}`)
|
|
18
|
+
.digest('hex')
|
|
19
|
+
.slice(0, 16)
|
|
20
|
+
return `__temp_${hash}`
|
|
21
|
+
}
|
|
22
|
+
|
|
5
23
|
export type ExtractedFunctionName = {
|
|
6
24
|
pikkuFuncId: string
|
|
7
25
|
name: string
|
|
@@ -126,7 +144,7 @@ export function extractFunctionName(
|
|
|
126
144
|
}
|
|
127
145
|
|
|
128
146
|
if (!result.pikkuFuncId) {
|
|
129
|
-
result.pikkuFuncId =
|
|
147
|
+
result.pikkuFuncId = tempFuncId(callExpr, rootDir)
|
|
130
148
|
}
|
|
131
149
|
|
|
132
150
|
populateNameByPriority(result)
|
|
@@ -440,7 +458,7 @@ export function extractFunctionName(
|
|
|
440
458
|
} else if (result.exportedName) {
|
|
441
459
|
result.pikkuFuncId = result.exportedName
|
|
442
460
|
} else {
|
|
443
|
-
result.pikkuFuncId =
|
|
461
|
+
result.pikkuFuncId = tempFuncId(callExpr, rootDir)
|
|
444
462
|
}
|
|
445
463
|
|
|
446
464
|
if (result.version !== null) {
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import * as ts from 'typescript'
|
|
2
|
+
import { createHash } from 'crypto'
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
|
|
2
4
|
import { dirname, join, resolve } from 'path'
|
|
3
5
|
import { createGenerator, RootlessError } from 'ts-json-schema-generator'
|
|
4
6
|
import { register } from 'tsx/esm/api'
|
|
@@ -74,6 +76,74 @@ let cachedTsconfigPath: string | undefined
|
|
|
74
76
|
let cachedCustomTypesContent: string | undefined
|
|
75
77
|
let cachedTSSchemas: Record<string, JSONValue> | undefined
|
|
76
78
|
|
|
79
|
+
const SCHEMA_CACHE_VERSION = 1
|
|
80
|
+
|
|
81
|
+
// This package's own version — folded into the cache key so that upgrading
|
|
82
|
+
// @pikku/inspector (the channel through which a schema-format change ships)
|
|
83
|
+
// auto-invalidates every on-disk cache, without relying on someone remembering
|
|
84
|
+
// to bump SCHEMA_CACHE_VERSION. Read once; falls back to the constant if the
|
|
85
|
+
// package.json can't be located (e.g. an unexpected bundling layout).
|
|
86
|
+
const inspectorVersion: string = (() => {
|
|
87
|
+
try {
|
|
88
|
+
const pkgUrl = new URL('../../package.json', import.meta.url)
|
|
89
|
+
const pkg = JSON.parse(readFileSync(pkgUrl, 'utf-8'))
|
|
90
|
+
return typeof pkg.version === 'string' ? pkg.version : `v${SCHEMA_CACHE_VERSION}`
|
|
91
|
+
} catch {
|
|
92
|
+
return `v${SCHEMA_CACHE_VERSION}`
|
|
93
|
+
}
|
|
94
|
+
})()
|
|
95
|
+
|
|
96
|
+
// Key the TS-schema cache on everything that affects its output: the generated
|
|
97
|
+
// custom-types source, the generator options that change schema shape, and the
|
|
98
|
+
// inspector version (schema-format changes ship with a version bump).
|
|
99
|
+
function tsSchemaCacheKey(
|
|
100
|
+
customTypesContent: string,
|
|
101
|
+
config: { schemasFromTypes?: string[]; schema?: { additionalProperties?: boolean } }
|
|
102
|
+
): string {
|
|
103
|
+
return createHash('sha1')
|
|
104
|
+
.update(`v${SCHEMA_CACHE_VERSION}\0`)
|
|
105
|
+
.update(`pkg:${inspectorVersion}\0`)
|
|
106
|
+
.update(`ap:${config.schema?.additionalProperties ? 1 : 0}\0`)
|
|
107
|
+
.update(`ft:${(config.schemasFromTypes ?? []).join(',')}\0`)
|
|
108
|
+
.update(customTypesContent)
|
|
109
|
+
.digest('hex')
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function schemaCacheFile(cacheDir: string): string {
|
|
113
|
+
return join(cacheDir, 'ts-schemas.json')
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function readDiskTSSchemas(
|
|
117
|
+
logger: InspectorLogger,
|
|
118
|
+
cacheDir: string,
|
|
119
|
+
key: string
|
|
120
|
+
): Record<string, JSONValue> | null {
|
|
121
|
+
const file = schemaCacheFile(cacheDir)
|
|
122
|
+
if (!existsSync(file)) return null
|
|
123
|
+
try {
|
|
124
|
+
const parsed = JSON.parse(readFileSync(file, 'utf-8'))
|
|
125
|
+
if (parsed?.key === key && parsed.schemas) return parsed.schemas
|
|
126
|
+
} catch (e) {
|
|
127
|
+
logger.debug(`Ignoring unreadable TS-schema cache: ${(e as Error).message}`)
|
|
128
|
+
}
|
|
129
|
+
return null
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function writeDiskTSSchemas(
|
|
133
|
+
logger: InspectorLogger,
|
|
134
|
+
cacheDir: string,
|
|
135
|
+
key: string,
|
|
136
|
+
schemas: Record<string, JSONValue>
|
|
137
|
+
): void {
|
|
138
|
+
const file = schemaCacheFile(cacheDir)
|
|
139
|
+
try {
|
|
140
|
+
mkdirSync(dirname(file), { recursive: true })
|
|
141
|
+
writeFileSync(file, JSON.stringify({ key, schemas }))
|
|
142
|
+
} catch (e) {
|
|
143
|
+
logger.debug(`Failed to persist TS-schema cache: ${(e as Error).message}`)
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
77
147
|
function createProgramWithVirtualFile(
|
|
78
148
|
tsconfig: string,
|
|
79
149
|
virtualFilePath: string,
|
|
@@ -494,6 +564,7 @@ export async function generateAllSchemas(
|
|
|
494
564
|
tsconfig: string
|
|
495
565
|
schemasFromTypes?: string[]
|
|
496
566
|
schema?: { additionalProperties?: boolean }
|
|
567
|
+
cacheDir?: string
|
|
497
568
|
},
|
|
498
569
|
state: InspectorState
|
|
499
570
|
): Promise<Record<string, JSONValue>> {
|
|
@@ -509,11 +580,28 @@ export async function generateAllSchemas(
|
|
|
509
580
|
requiredTypes
|
|
510
581
|
)
|
|
511
582
|
|
|
583
|
+
// Fast path: same process, types unchanged — reuse the in-memory result.
|
|
512
584
|
if (cachedTSSchemas && cachedCustomTypesContent === customTypesContent) {
|
|
513
585
|
logger.debug('Reusing cached TS schemas (types unchanged)')
|
|
514
586
|
return { ...cachedTSSchemas, ...zodSchemas }
|
|
515
587
|
}
|
|
516
588
|
|
|
589
|
+
// Disk path: a prior `pikku all` left a cache whose key matches the current
|
|
590
|
+
// custom types — load it and skip ts-json-schema-generator (the dominant
|
|
591
|
+
// cold-run cost). Zod schemas are always regenerated (cheap, ~1ms/schema).
|
|
592
|
+
const cacheKey = config.cacheDir
|
|
593
|
+
? tsSchemaCacheKey(customTypesContent, config)
|
|
594
|
+
: null
|
|
595
|
+
if (config.cacheDir && cacheKey) {
|
|
596
|
+
const disk = readDiskTSSchemas(logger, config.cacheDir, cacheKey)
|
|
597
|
+
if (disk) {
|
|
598
|
+
logger.debug('Reusing on-disk TS schemas (types unchanged across runs)')
|
|
599
|
+
cachedCustomTypesContent = customTypesContent
|
|
600
|
+
cachedTSSchemas = disk
|
|
601
|
+
return { ...disk, ...zodSchemas }
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
517
605
|
const tsSchemas = generateTSSchemas(
|
|
518
606
|
logger,
|
|
519
607
|
config.tsconfig,
|
|
@@ -529,5 +617,9 @@ export async function generateAllSchemas(
|
|
|
529
617
|
cachedCustomTypesContent = customTypesContent
|
|
530
618
|
cachedTSSchemas = tsSchemas
|
|
531
619
|
|
|
620
|
+
if (config.cacheDir && cacheKey) {
|
|
621
|
+
writeDiskTSSchemas(logger, config.cacheDir, cacheKey, tsSchemas)
|
|
622
|
+
}
|
|
623
|
+
|
|
532
624
|
return { ...tsSchemas, ...zodSchemas }
|
|
533
625
|
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { strict as assert } from 'assert'
|
|
2
|
+
import { describe, test } from 'node:test'
|
|
3
|
+
import type { WorkflowStepMeta } from '@pikku/core/workflow'
|
|
4
|
+
import { deriveWorkflowPlan } from './derive-workflow-plan.js'
|
|
5
|
+
|
|
6
|
+
const rpc = (stepName: string, rpcName = 'rpc.fn'): WorkflowStepMeta =>
|
|
7
|
+
({ type: 'rpc', stepName, rpcName }) as WorkflowStepMeta
|
|
8
|
+
const sleep = (stepName: string): WorkflowStepMeta =>
|
|
9
|
+
({ type: 'sleep', stepName }) as WorkflowStepMeta
|
|
10
|
+
const inline = (stepName: string): WorkflowStepMeta =>
|
|
11
|
+
({ type: 'inline', stepName }) as WorkflowStepMeta
|
|
12
|
+
|
|
13
|
+
describe('deriveWorkflowPlan', () => {
|
|
14
|
+
test('linear workflow is deterministic with every step listed in order', () => {
|
|
15
|
+
const plan = deriveWorkflowPlan([rpc('a'), sleep('b'), inline('c')])
|
|
16
|
+
assert.equal(plan.deterministic, true)
|
|
17
|
+
assert.deepEqual(plan.plannedSteps, [
|
|
18
|
+
{ stepName: 'a' },
|
|
19
|
+
{ stepName: 'b' },
|
|
20
|
+
{ stepName: 'c' },
|
|
21
|
+
])
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
test('parallel group lists each child step', () => {
|
|
25
|
+
const plan = deriveWorkflowPlan([
|
|
26
|
+
rpc('a'),
|
|
27
|
+
{
|
|
28
|
+
type: 'parallel',
|
|
29
|
+
children: [rpc('p1'), rpc('p2')],
|
|
30
|
+
} as WorkflowStepMeta,
|
|
31
|
+
])
|
|
32
|
+
assert.equal(plan.deterministic, true)
|
|
33
|
+
assert.deepEqual(plan.plannedSteps, [
|
|
34
|
+
{ stepName: 'a' },
|
|
35
|
+
{ stepName: 'p1' },
|
|
36
|
+
{ stepName: 'p2' },
|
|
37
|
+
])
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test('branch is loopless: plannedSteps cover every arm but not deterministic', () => {
|
|
41
|
+
const plan = deriveWorkflowPlan([
|
|
42
|
+
rpc('start'),
|
|
43
|
+
{
|
|
44
|
+
type: 'branch',
|
|
45
|
+
branches: [{ condition: 'x', steps: [rpc('left')] }],
|
|
46
|
+
elseSteps: [rpc('right')],
|
|
47
|
+
} as WorkflowStepMeta,
|
|
48
|
+
rpc('end'),
|
|
49
|
+
])
|
|
50
|
+
assert.equal(plan.deterministic, false, 'a branch picks a path at runtime')
|
|
51
|
+
assert.deepEqual(plan.plannedSteps, [
|
|
52
|
+
{ stepName: 'start' },
|
|
53
|
+
{ stepName: 'left' },
|
|
54
|
+
{ stepName: 'right' },
|
|
55
|
+
{ stepName: 'end' },
|
|
56
|
+
])
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
test('switch is loopless: cases + default contribute steps, not deterministic', () => {
|
|
60
|
+
const plan = deriveWorkflowPlan([
|
|
61
|
+
{
|
|
62
|
+
type: 'switch',
|
|
63
|
+
expression: 'kind',
|
|
64
|
+
cases: [{ steps: [rpc('caseA')] }, { steps: [rpc('caseB')] }],
|
|
65
|
+
defaultSteps: [rpc('fallback')],
|
|
66
|
+
} as WorkflowStepMeta,
|
|
67
|
+
])
|
|
68
|
+
assert.equal(plan.deterministic, false)
|
|
69
|
+
assert.deepEqual(plan.plannedSteps, [
|
|
70
|
+
{ stepName: 'caseA' },
|
|
71
|
+
{ stepName: 'caseB' },
|
|
72
|
+
{ stepName: 'fallback' },
|
|
73
|
+
])
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test('fanout (loop) yields no plannedSteps and is not deterministic', () => {
|
|
77
|
+
const plan = deriveWorkflowPlan([
|
|
78
|
+
rpc('before'),
|
|
79
|
+
{
|
|
80
|
+
type: 'fanout',
|
|
81
|
+
stepName: 'each',
|
|
82
|
+
child: rpc('child'),
|
|
83
|
+
mode: 'parallel',
|
|
84
|
+
} as WorkflowStepMeta,
|
|
85
|
+
])
|
|
86
|
+
assert.equal(plan.deterministic, false)
|
|
87
|
+
assert.equal(plan.plannedSteps, undefined, 'loop count is runtime-dependent')
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
test('a fanout nested inside a branch still suppresses the plan', () => {
|
|
91
|
+
const plan = deriveWorkflowPlan([
|
|
92
|
+
{
|
|
93
|
+
type: 'branch',
|
|
94
|
+
branches: [
|
|
95
|
+
{
|
|
96
|
+
condition: 'x',
|
|
97
|
+
steps: [
|
|
98
|
+
{
|
|
99
|
+
type: 'fanout',
|
|
100
|
+
stepName: 'each',
|
|
101
|
+
child: rpc('child'),
|
|
102
|
+
mode: 'serial',
|
|
103
|
+
} as WorkflowStepMeta,
|
|
104
|
+
],
|
|
105
|
+
},
|
|
106
|
+
],
|
|
107
|
+
} as WorkflowStepMeta,
|
|
108
|
+
])
|
|
109
|
+
assert.equal(plan.deterministic, false)
|
|
110
|
+
assert.equal(plan.plannedSteps, undefined)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
test('non-named steps (set/return/cancel) are skipped', () => {
|
|
114
|
+
const plan = deriveWorkflowPlan([
|
|
115
|
+
{ type: 'set' } as WorkflowStepMeta,
|
|
116
|
+
rpc('a'),
|
|
117
|
+
{ type: 'return' } as WorkflowStepMeta,
|
|
118
|
+
])
|
|
119
|
+
assert.equal(plan.deterministic, true)
|
|
120
|
+
assert.deepEqual(plan.plannedSteps, [{ stepName: 'a' }])
|
|
121
|
+
})
|
|
122
|
+
})
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
WorkflowStepMeta,
|
|
3
|
+
WorkflowPlannedStep,
|
|
4
|
+
} from '@pikku/core/workflow'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Derive the static UI plan for a DSL workflow from its extracted step tree.
|
|
8
|
+
*
|
|
9
|
+
* `plannedSteps` is the ordered list of every named step the workflow can run,
|
|
10
|
+
* so a frontend can render the step skeleton up front without executing the
|
|
11
|
+
* workflow or hand-listing steps. It is populated whenever the workflow has NO
|
|
12
|
+
* loops (fanout) — loops make the step COUNT runtime-dependent, so a workflow
|
|
13
|
+
* containing any fanout gets neither field.
|
|
14
|
+
*
|
|
15
|
+
* `deterministic` is true only when the exact executed sequence is known up
|
|
16
|
+
* front: a flat list of named steps with no branches/switches (which pick a
|
|
17
|
+
* path at runtime) and no loops. A branchy-but-loopless workflow is therefore
|
|
18
|
+
* `deterministic: false` but still gets `plannedSteps` (the full set of
|
|
19
|
+
* possible steps, in source order).
|
|
20
|
+
*/
|
|
21
|
+
export function deriveWorkflowPlan(steps: WorkflowStepMeta[]): {
|
|
22
|
+
deterministic: boolean
|
|
23
|
+
plannedSteps?: WorkflowPlannedStep[]
|
|
24
|
+
} {
|
|
25
|
+
if (containsLoop(steps)) {
|
|
26
|
+
return { deterministic: false }
|
|
27
|
+
}
|
|
28
|
+
return {
|
|
29
|
+
deterministic: !containsConditional(steps),
|
|
30
|
+
plannedSteps: collectNamedSteps(steps),
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Any fanout (loop) anywhere in the tree — including inside branches/switches. */
|
|
35
|
+
function containsLoop(steps: WorkflowStepMeta[]): boolean {
|
|
36
|
+
return steps.some((step) => {
|
|
37
|
+
switch (step.type) {
|
|
38
|
+
case 'fanout':
|
|
39
|
+
return true
|
|
40
|
+
case 'branch':
|
|
41
|
+
return (
|
|
42
|
+
step.branches.some((b) => containsLoop(b.steps)) ||
|
|
43
|
+
(step.elseSteps ? containsLoop(step.elseSteps) : false)
|
|
44
|
+
)
|
|
45
|
+
case 'switch':
|
|
46
|
+
return (
|
|
47
|
+
step.cases.some((c) => containsLoop(c.steps)) ||
|
|
48
|
+
(step.defaultSteps ? containsLoop(step.defaultSteps) : false)
|
|
49
|
+
)
|
|
50
|
+
default:
|
|
51
|
+
return false
|
|
52
|
+
}
|
|
53
|
+
})
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Any branch/switch — the run takes a runtime-decided path. */
|
|
57
|
+
function containsConditional(steps: WorkflowStepMeta[]): boolean {
|
|
58
|
+
return steps.some((step) => step.type === 'branch' || step.type === 'switch')
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Flatten named steps (rpc/inline/sleep/parallel children) in source order. */
|
|
62
|
+
function collectNamedSteps(steps: WorkflowStepMeta[]): WorkflowPlannedStep[] {
|
|
63
|
+
const planned: WorkflowPlannedStep[] = []
|
|
64
|
+
for (const step of steps) {
|
|
65
|
+
switch (step.type) {
|
|
66
|
+
case 'rpc':
|
|
67
|
+
case 'inline':
|
|
68
|
+
case 'sleep':
|
|
69
|
+
planned.push({ stepName: step.stepName })
|
|
70
|
+
break
|
|
71
|
+
case 'parallel':
|
|
72
|
+
for (const child of step.children) {
|
|
73
|
+
planned.push({ stepName: child.stepName })
|
|
74
|
+
}
|
|
75
|
+
break
|
|
76
|
+
case 'branch':
|
|
77
|
+
for (const b of step.branches) planned.push(...collectNamedSteps(b.steps))
|
|
78
|
+
if (step.elseSteps) planned.push(...collectNamedSteps(step.elseSteps))
|
|
79
|
+
break
|
|
80
|
+
case 'switch':
|
|
81
|
+
for (const c of step.cases) planned.push(...collectNamedSteps(c.steps))
|
|
82
|
+
if (step.defaultSteps) {
|
|
83
|
+
planned.push(...collectNamedSteps(step.defaultSteps))
|
|
84
|
+
}
|
|
85
|
+
break
|
|
86
|
+
// set / return / cancel / filter / arrayPredicate produce no named step
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return planned
|
|
90
|
+
}
|
|
@@ -3,6 +3,7 @@ import { isVersionedId, formatVersionedId, parseVersionedId } from '@pikku/core'
|
|
|
3
3
|
import type { SerializedWorkflowGraph } from './workflow-graph.types.js'
|
|
4
4
|
import { canonicalJSON, hashString } from '../../hash.js'
|
|
5
5
|
import { convertDslToGraph } from './convert-dsl-to-graph.js'
|
|
6
|
+
import { deriveWorkflowPlan } from '../derive-workflow-plan.js'
|
|
6
7
|
import type { InspectorState } from '../../../types.js'
|
|
7
8
|
|
|
8
9
|
export function finalizeWorkflows(state: InspectorState): void {
|
|
@@ -14,6 +15,20 @@ export function finalizeWorkflows(state: InspectorState): void {
|
|
|
14
15
|
stampVersionsOnGraph(graph, functionsMeta)
|
|
15
16
|
computeStepHashes(graph, functionsMeta)
|
|
16
17
|
graph.graphHash = computeGraphHash(graph)
|
|
18
|
+
// Predictable (loopless) DSL workflows carry their full step list so a UI
|
|
19
|
+
// can render the skeleton up front without executing the run. Only DSL is
|
|
20
|
+
// gated: a complex workflow's step tree is incomplete (inline JS branches
|
|
21
|
+
// aren't captured) and flattens loops into plain steps, so its plan would
|
|
22
|
+
// lie about determinism.
|
|
23
|
+
if (graph.source === 'dsl') {
|
|
24
|
+
const { deterministic, plannedSteps } = deriveWorkflowPlan(meta.steps)
|
|
25
|
+
graph.deterministic = deterministic
|
|
26
|
+
// Omit an empty list — a deterministic workflow with no plannedSteps is
|
|
27
|
+
// simply one with no named steps (e.g. a bare return).
|
|
28
|
+
if (plannedSteps?.length) {
|
|
29
|
+
graph.plannedSteps = plannedSteps
|
|
30
|
+
}
|
|
31
|
+
}
|
|
17
32
|
workflows.graphMeta[name] = graph
|
|
18
33
|
}
|
|
19
34
|
|
|
@@ -108,7 +108,11 @@ export type FlowType =
|
|
|
108
108
|
| 'set'
|
|
109
109
|
|
|
110
110
|
// Import and re-export context types from core
|
|
111
|
-
import type {
|
|
111
|
+
import type {
|
|
112
|
+
ContextVariable,
|
|
113
|
+
WorkflowContext,
|
|
114
|
+
WorkflowPlannedStep,
|
|
115
|
+
} from '@pikku/core/workflow'
|
|
112
116
|
|
|
113
117
|
export type { ContextVariable, WorkflowContext }
|
|
114
118
|
|
|
@@ -202,6 +206,19 @@ export interface SerializedWorkflowGraph {
|
|
|
202
206
|
entryNodeIds: string[]
|
|
203
207
|
/** Hash of graph topology (nodes, edges, input mappings) */
|
|
204
208
|
graphHash?: string
|
|
209
|
+
/**
|
|
210
|
+
* True when the exact executed step sequence is known up front: a loopless
|
|
211
|
+
* DSL workflow with no branches/switches. Lets a UI render the run as a fixed
|
|
212
|
+
* pipeline. Loops (fanout) → omitted; branchy-but-loopless → false.
|
|
213
|
+
*/
|
|
214
|
+
deterministic?: boolean
|
|
215
|
+
/**
|
|
216
|
+
* Every named step the workflow can run, in source order — so a frontend can
|
|
217
|
+
* render the step skeleton before the run starts. Populated for any loopless
|
|
218
|
+
* DSL workflow (a branchy one lists all possible steps); omitted when the step
|
|
219
|
+
* count is runtime-dependent (any fanout).
|
|
220
|
+
*/
|
|
221
|
+
plannedSteps?: WorkflowPlannedStep[]
|
|
205
222
|
/** Wire entry points (HTTP, channel, queue, etc.) that trigger this workflow */
|
|
206
223
|
wires?: WorkflowWires
|
|
207
224
|
}
|
package/src/visit.ts
CHANGED
|
@@ -100,6 +100,28 @@ export const visitSetup = (
|
|
|
100
100
|
)
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
+
// Register every pikku function before transports/wirings are resolved, so that
|
|
104
|
+
// resolution (e.g. a channel handler referencing a function defined in another
|
|
105
|
+
// file) is independent of source-file traversal order. Runs between visitSetup
|
|
106
|
+
// and visitRoutes.
|
|
107
|
+
export const visitFunctions = (
|
|
108
|
+
logger: InspectorLogger,
|
|
109
|
+
checker: ts.TypeChecker,
|
|
110
|
+
node: ts.Node,
|
|
111
|
+
state: InspectorState,
|
|
112
|
+
options: InspectorOptions
|
|
113
|
+
) => {
|
|
114
|
+
const nextOptions = ts.isSourceFile(node)
|
|
115
|
+
? { ...options, sourceFile: node }
|
|
116
|
+
: options
|
|
117
|
+
|
|
118
|
+
addFunctions(logger, node, checker, state, nextOptions)
|
|
119
|
+
|
|
120
|
+
ts.forEachChild(node, (child) =>
|
|
121
|
+
visitFunctions(logger, checker, child, state, nextOptions)
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
|
|
103
125
|
export const visitRoutes = (
|
|
104
126
|
logger: InspectorLogger,
|
|
105
127
|
checker: ts.TypeChecker,
|
|
@@ -113,7 +135,9 @@ export const visitRoutes = (
|
|
|
113
135
|
|
|
114
136
|
checkAddonBans(logger, node, checker, state, nextOptions)
|
|
115
137
|
|
|
116
|
-
addFunctions
|
|
138
|
+
// NOTE: addFunctions runs in its own earlier pass (visitFunctions) so that
|
|
139
|
+
// every function is registered before any wiring (channels, CLI, etc.)
|
|
140
|
+
// resolves it — wiring resolution must not depend on source-file order.
|
|
117
141
|
addAuth(logger, node, checker, state, nextOptions)
|
|
118
142
|
addSecret(logger, node, checker, state, nextOptions)
|
|
119
143
|
addCredential(logger, node, checker, state, nextOptions)
|