@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.
@@ -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
 
@@ -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
- await inspect(logger, [file], { rootDir: tmpDir })
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
- // Second sweep: add all transports
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 { randomUUID } from 'crypto'
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 = `__temp_${randomUUID()}`
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 = `__temp_${randomUUID()}`
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 { ContextVariable, WorkflowContext } from '@pikku/core/workflow'
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(logger, node, checker, state, nextOptions)
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)