@pikku/inspector 0.12.26 → 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.
@@ -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)