@pikku/inspector 0.12.26 → 0.12.28

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.
Files changed (40) hide show
  1. package/CHANGELOG.md +115 -0
  2. package/dist/add/add-functions.js +15 -4
  3. package/dist/add/add-rpc-invocations.js +27 -0
  4. package/dist/error-codes.d.ts +2 -1
  5. package/dist/error-codes.js +1 -0
  6. package/dist/inspector.js +18 -3
  7. package/dist/types.d.ts +14 -0
  8. package/dist/utils/extract-function-name.js +20 -3
  9. package/dist/utils/filter-inspector-state.js +7 -6
  10. package/dist/utils/post-process.js +8 -1
  11. package/dist/utils/resolve-deploy-target.d.ts +3 -2
  12. package/dist/utils/resolve-deploy-target.js +4 -3
  13. package/dist/utils/schema-generator.d.ts +1 -0
  14. package/dist/utils/schema-generator.js +76 -0
  15. package/dist/utils/workflow/derive-workflow-plan.d.ts +20 -0
  16. package/dist/utils/workflow/derive-workflow-plan.js +78 -0
  17. package/dist/utils/workflow/graph/finalize-workflows.js +15 -0
  18. package/dist/utils/workflow/graph/workflow-graph.types.d.ts +14 -3
  19. package/dist/visit.d.ts +1 -0
  20. package/dist/visit.js +14 -1
  21. package/package.json +2 -2
  22. package/src/add/add-functions.ts +15 -4
  23. package/src/add/add-rpc-invocations.ts +41 -0
  24. package/src/add/pii-check.test.ts +3 -1
  25. package/src/add/rpc-type-cast.test.ts +123 -0
  26. package/src/error-codes.ts +2 -0
  27. package/src/inspector.ts +24 -2
  28. package/src/types.ts +17 -0
  29. package/src/utils/extract-function-name.ts +21 -3
  30. package/src/utils/filter-inspector-state.ts +13 -7
  31. package/src/utils/post-process.ts +8 -1
  32. package/src/utils/resolve-deploy-target.test.ts +30 -0
  33. package/src/utils/resolve-deploy-target.ts +5 -3
  34. package/src/utils/schema-generator.ts +92 -0
  35. package/src/utils/workflow/derive-workflow-plan.test.ts +122 -0
  36. package/src/utils/workflow/derive-workflow-plan.ts +90 -0
  37. package/src/utils/workflow/graph/finalize-workflows.ts +15 -0
  38. package/src/utils/workflow/graph/workflow-graph.types.ts +18 -3
  39. package/src/visit.ts +25 -1
  40. package/tsconfig.tsbuildinfo +1 -1
package/CHANGELOG.md CHANGED
@@ -1,3 +1,118 @@
1
+ ## 0.12.28
2
+
3
+ ### Patch Changes
4
+
5
+ - 66d43d1: Add `deploy.defaultTarget` to `pikku.config.json` to override the default deploy target ('serverless') for functions without an explicit `deploy` flag.
6
+ - a8c9e6d: feat(inspector): add PKU940 — block type casts on rpc.invoke() calls
7
+
8
+ The inspector now emits a critical PKU940 error when `rpc.invoke()` is called
9
+ with an `as` cast on an argument (`rpc.invoke('fn', data as any)`) or when its
10
+ result is cast (`rpc.invoke('fn', data) as any`). Both patterns defeat Pikku's
11
+ generated type safety and are rejected at build time.
12
+
13
+ - ba1ab08: refactor(workflow): replace `inline: false` with `workflowQueued: true` on function meta
14
+
15
+ The per-function workflow dispatch flag has been renamed from the confusing
16
+ negative `inline: false` to the explicit positive `workflowQueued: true`.
17
+ Two companion fields are also added: `workflowRetries` and `workflowTimeout`
18
+ as function-level equivalents of the per-call-site `NodeOptions` fields.
19
+
20
+ **Breaking change (patch — flag was undocumented):** rename `inline: false`
21
+ to `workflowQueued: true` on any `pikkuSessionlessFunc` / `pikkuFunc` that
22
+ dispatches its workflow steps via the queue.
23
+
24
+ **Behaviour change:** a step marked `workflowQueued: true` now throws if no
25
+ queue service is configured, instead of silently falling back to inline
26
+ execution.
27
+
28
+ **Bug fix:** `post-process.ts` was registering `wf-step-*` queues for every
29
+ workflow step node; it now only registers them for steps that are actually
30
+ `workflowQueued: true`, avoiding spurious queue resource allocation.
31
+
32
+ - Updated dependencies [ba1ab08]
33
+ - @pikku/core@0.12.40
34
+
35
+ ## 0.12.27
36
+
37
+ ### Patch Changes
38
+
39
+ - 41ff485: fix(inspector): register functions in a dedicated pass before wiring resolution
40
+
41
+ The deterministic-codegen change sorted `program.getSourceFiles()` so generated
42
+ output is byte-identical across runs. But function registration (`addFunctions`)
43
+ ran in the same sweep as wiring resolution (`visitRoutes`), so once traversal
44
+ became alphabetical, a wiring file could be visited before the file that defines
45
+ the function it references — e.g. an addon contract (`hello.contracts.ts`)
46
+ before `hello.functions.ts` — producing a spurious `PKU559` ("No function
47
+ metadata found for channel handler").
48
+
49
+ Function registration now runs in its own pass (`visitFunctions`) over the
50
+ sorted files, completing before any transport/wiring resolution, so resolution
51
+ no longer depends on source-file order. Also sort the `register.gen.ts` schema
52
+ imports (driven by a `Set`) so that file is stable too, and opt the PII-check
53
+ tests into the now-opt-in classification scan.
54
+
55
+ - d2078c8: fix(inspector): make codegen output deterministic across runs
56
+
57
+ Two sources of non-reproducible `pikku all` output are fixed:
58
+ 1. **Random placeholder ids.** Anonymous/unnamed functions and inline (non-exported) permissions were given a `__temp_${randomUUID()}` id, so a referenced-but-not-exported `pikkuPermission` const (e.g. `permissions: { admin: [requiresPlatformAdmin] }`) produced a fresh UUID in the generated meta on every run. The placeholder is now derived deterministically from the call expression's source location (relative path + start offset), still `__temp_`-prefixed so downstream name resolution is unchanged.
59
+ 2. **Unstable file-traversal order.** The two inspector sweeps iterated `program.getSourceFiles()` in glob + import-graph order, which varies run to run, so meta keys (and anything serialized in insertion order) were emitted in a different order each time — making a plain `git diff` of generated files look like functions were appearing/vanishing when the set was identical. Source files are now sorted by file name before the sweeps.
60
+
61
+ Net effect: byte-identical generated output across repeated runs with no source changes (verified across the full `.pikku` tree of a 331-function project).
62
+
63
+ - e6fd12b: perf(inspector,cli): persist generated TS schemas to disk across runs
64
+
65
+ `generateAllSchemas` already cached its `ts-json-schema-generator` output
66
+ in-memory (keyed by the generated custom-types content), so the 2nd and 3rd
67
+ inspector passes within a single `pikku all` were near-free. But the cache
68
+ never survived the process, so every fresh `pikku all` paid the full cold cost
69
+ of building a second TS program + running ts-json-schema-generator — on a
70
+ 331-function project that's ~2.2s, the single largest line item of a run.
71
+
72
+ The cache is now also persisted to disk (`node_modules/.cache/pikku/ts-schemas.json`,
73
+ gitignored by convention), keyed by a hash of the custom-types content plus the
74
+ generator options that affect output. A warm `pikku all` whose function types
75
+ are unchanged loads the schemas from disk and skips schema generation entirely;
76
+ the cold first pass drops by ~3.4s in practice (it also primes the in-memory
77
+ cache for the re-inspect passes). Zod schemas are still regenerated every run
78
+ (already ~1ms each). Output is byte-identical to a cold run (verified across the
79
+ full generated tree). The key is derived from the same content the in-memory
80
+ cache uses, so any type change busts it. It also folds in the `@pikku/inspector`
81
+ package version, so upgrading the inspector (the channel a schema-format change
82
+ ships through) auto-invalidates every cache; `SCHEMA_CACHE_VERSION` remains a
83
+ manual lever for in-development format changes between releases.
84
+
85
+ Opt-out: omit `schemaConfig.cacheDir` (the CLI sets it by default).
86
+
87
+ - 244d892: perf(cli,inspector): make the data-classification scan opt-in (`pikku all --security`)
88
+
89
+ `pikku all` was spending the bulk of its wall-clock on the data-classification
90
+ leak check. For every function, on every inspector pass, it called
91
+ `checker.getReturnTypeOfSignature` to infer the handler's return type and scan it
92
+ for `Private`/`Pii`/`Secret` brands — the single most expensive type-checker
93
+ operation. On a 331-function project that was ~7.3s (≈half the total), repeated
94
+ across all three inspector passes, even though the scan only emits diagnostics
95
+ and never affects generated output.
96
+
97
+ The scan is a security lint, not codegen, so it's now **off by default** and gated
98
+ behind a new `--security` flag (or `security: true` in the config). A plain
99
+ `pikku all` skips return-type inference entirely; run `pikku all --security`
100
+ (optionally with `--fail-on-error`) in CI/pre-deploy to enforce it. On the
101
+ 331-function project this cut `pikku all` from ~15.3s to ~9.6s.
102
+
103
+ Also: the `all` command now reads back the run's recorded per-step durations and,
104
+ under `PIKKU_TIMING=1`, prints a slowest-first timing table — making it easy to
105
+ see where codegen time goes without adding any hot-path instrumentation.
106
+
107
+ - 940c253: Populate `plannedSteps` and `deterministic` on serialized DSL workflow graphs. For a DSL workflow with no loops (fanout), the inspector now records every named step in source order, so a UI can render the run's step skeleton up front without executing it or hand-listing steps. `deterministic` is `true` only for a flat, loopless, branch-free workflow (exact sequence known ahead of time); a branchy-but-loopless workflow lists all possible steps with `deterministic: false`; any fanout makes the count runtime-dependent so neither field is emitted (just `deterministic: false`). Only `source: 'dsl'` workflows are planned — `complex` step trees omit inline branches and flatten loops, so their plans would misreport determinism. The runtime already threads these fields from workflow meta onto each run via `getRun`.
108
+ - Updated dependencies [4be205f]
109
+ - Updated dependencies [061c717]
110
+ - Updated dependencies [2c55e13]
111
+ - Updated dependencies [c745c26]
112
+ - Updated dependencies [57900b5]
113
+ - Updated dependencies [72694f6]
114
+ - @pikku/core@0.12.39
115
+
1
116
  ## 0.12.26
2
117
 
3
118
  ### Patch Changes
@@ -277,7 +277,9 @@ export const addFunctions = (logger, node, checker, state, options) => {
277
277
  let deploy;
278
278
  let approvalRequired;
279
279
  let approvalDescription;
280
- let inline;
280
+ let workflowQueued;
281
+ let workflowRetries;
282
+ let workflowTimeout;
281
283
  let version;
282
284
  let objectNode;
283
285
  let nodeDisplayName = null;
@@ -345,7 +347,9 @@ export const addFunctions = (logger, node, checker, state, options) => {
345
347
  readonly_ = getPropertyValue(firstArg, 'readonly');
346
348
  deploy = getPropertyValue(firstArg, 'deploy');
347
349
  approvalRequired = getPropertyValue(firstArg, 'approvalRequired');
348
- inline = getPropertyValue(firstArg, 'inline');
350
+ workflowQueued = getPropertyValue(firstArg, 'workflowQueued');
351
+ workflowRetries = getPropertyValue(firstArg, 'workflowRetries');
352
+ workflowTimeout = getPropertyValue(firstArg, 'workflowTimeout');
349
353
  // Extract approvalDescription identifier reference
350
354
  for (const prop of firstArg.properties) {
351
355
  if (ts.isPropertyAssignment(prop) &&
@@ -647,7 +651,12 @@ export const addFunctions = (logger, node, checker, state, options) => {
647
651
  // secret → never returned by any exposed function (sessioned or not)
648
652
  // private → only visible to authenticated (sessioned) users; ok for pikkuFunc
649
653
  // public → safe for sessionless functions
650
- {
654
+ // Opt-in only: inferring every handler's return type (getReturnTypeOfSignature)
655
+ // is the single most expensive checker operation and dominates `pikku all`
656
+ // wall-clock. The classification leak scan is a security lint, not codegen, so
657
+ // it runs ONLY when explicitly requested (`pikku all --security`) — see the
658
+ // classificationCheck option. Default codegen skips it entirely.
659
+ if (options.classificationCheck) {
651
660
  const sig = checker.getSignatureFromDeclaration(handler);
652
661
  if (sig) {
653
662
  const rawRet = checker.getReturnTypeOfSignature(sig);
@@ -735,7 +744,9 @@ export const addFunctions = (logger, node, checker, state, options) => {
735
744
  deploy: deploy || undefined,
736
745
  approvalRequired: approvalRequired || undefined,
737
746
  approvalDescription: approvalDescription || undefined,
738
- inline: inline === false ? false : undefined,
747
+ workflowQueued: workflowQueued === true ? true : undefined,
748
+ workflowRetries: workflowRetries ?? undefined,
749
+ workflowTimeout: workflowTimeout ?? undefined,
739
750
  implementationHash,
740
751
  version,
741
752
  title,
@@ -1,4 +1,18 @@
1
1
  import * as ts from 'typescript';
2
+ import { ErrorCode } from '../error-codes.js';
3
+ function hasTypeCast(node) {
4
+ return ts.isAsExpression(node) || ts.isTypeAssertionExpression(node);
5
+ }
6
+ function outerParent(node) {
7
+ let p = node.parent;
8
+ while (p && (ts.isAwaitExpression(p) || ts.isParenthesizedExpression(p))) {
9
+ p = p.parent;
10
+ }
11
+ return p;
12
+ }
13
+ function findCastArg(args) {
14
+ return args.find(hasTypeCast);
15
+ }
2
16
  /**
3
17
  * Helper to extract namespace from a namespaced function reference like 'ext:hello'
4
18
  */
@@ -52,6 +66,19 @@ export function addRPCInvocations(node, state, logger) {
52
66
  expression.name.text === 'invoke' &&
53
67
  ts.isIdentifier(expression.expression) &&
54
68
  expression.expression.text === 'rpc') {
69
+ // Skip PKU940 for generated files — they may contain intentional casts
70
+ // (e.g. the paginated useInfiniteQuery hook in pikku-react-query.gen.ts).
71
+ const sourceFileName = node.getSourceFile().fileName;
72
+ const isGenerated = sourceFileName.endsWith('.gen.ts') || sourceFileName.endsWith('.gen.js');
73
+ if (!isGenerated) {
74
+ if (hasTypeCast(outerParent(node))) {
75
+ logger.critical(ErrorCode.RPC_INVOCATION_TYPE_CAST, `rpc.invoke() result is type-cast — remove the 'as' expression and rely on Pikku's generated types`);
76
+ }
77
+ const castArg = findCastArg(args);
78
+ if (castArg) {
79
+ logger.critical(ErrorCode.RPC_INVOCATION_TYPE_CAST, `rpc.invoke() has a type cast on an argument — remove the 'as' expression and rely on Pikku's generated types`);
80
+ }
81
+ }
55
82
  const [firstArg] = args;
56
83
  if (firstArg) {
57
84
  if (ts.isStringLiteral(firstArg)) {
@@ -59,7 +59,8 @@ export declare enum ErrorCode {
59
59
  WORKFLOW_MULTI_QUEUE_NOT_SUPPORTED = "PKU901",
60
60
  PII_IN_OUTPUT = "PKU910",
61
61
  ADDON_WIRING_NOT_ALLOWED = "PKU920",
62
- ADDON_CONTRACT_HANDLERS_NOT_ALLOWED = "PKU921"
62
+ ADDON_CONTRACT_HANDLERS_NOT_ALLOWED = "PKU921",
63
+ RPC_INVOCATION_TYPE_CAST = "PKU940"
63
64
  }
64
65
  /**
65
66
  * Severity of a tracked, coded diagnostic. `critical` always blocks the build;
@@ -75,4 +75,5 @@ export var ErrorCode;
75
75
  // Addon authoring errors
76
76
  ErrorCode["ADDON_WIRING_NOT_ALLOWED"] = "PKU920";
77
77
  ErrorCode["ADDON_CONTRACT_HANDLERS_NOT_ALLOWED"] = "PKU921";
78
+ ErrorCode["RPC_INVOCATION_TYPE_CAST"] = "PKU940";
78
79
  })(ErrorCode || (ErrorCode = {}));
package/dist/inspector.js 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 { getFilesAndMethods } from './utils/get-files-and-methods.js';
7
7
  import { findCommonAncestor } from './utils/find-root-dir.js';
@@ -222,10 +222,17 @@ export const inspect = async (logger, routeFiles, options = {}) => {
222
222
  // node_modules under rootDir (e.g. a locally-installed addon) is a
223
223
  // dependency, not project source — scanning it double-counts the addon's
224
224
  // own application types (CoreConfig/Services/SingletonServices).
225
+ // Sort by file name so the sweeps populate state in a stable order. The
226
+ // program's own file order depends on glob + import-graph resolution, which
227
+ // varies run to run — leaving generated meta keys (and anything serialized
228
+ // in insertion order) non-reproducible across identical `pikku all` runs.
229
+ // Safe because function registration is a dedicated pass (visitFunctions)
230
+ // that completes before any order-sensitive wiring resolution in visitRoutes.
225
231
  const sourceFiles = program
226
232
  .getSourceFiles()
227
233
  .filter((sf) => sf.fileName.startsWith(rootDir) &&
228
- !sf.fileName.includes('/node_modules/'));
234
+ !sf.fileName.includes('/node_modules/'))
235
+ .sort((a, b) => a.fileName < b.fileName ? -1 : a.fileName > b.fileName ? 1 : 0);
229
236
  logger.debug(`Got source files in ${(performance.now() - startSourceFiles).toFixed(2)}ms`);
230
237
  const state = getInitialInspectorState(rootDir);
231
238
  // First sweep: add all functions
@@ -238,7 +245,15 @@ export const inspect = async (logger, routeFiles, options = {}) => {
238
245
  // Load addon function metadata so wirings can reference addon functions
239
246
  await loadAddonFunctionsMeta(logger, state);
240
247
  if (!options.setupOnly) {
241
- // Second sweep: add all transports
248
+ // Function sweep: register every function before transports/wirings resolve
249
+ // them, so resolution doesn't depend on source-file order.
250
+ const startFunctions = performance.now();
251
+ for (const sourceFile of sourceFiles) {
252
+ const sourceOptions = { ...options, sourceFile };
253
+ ts.forEachChild(sourceFile, (child) => visitFunctions(logger, checker, child, state, sourceOptions));
254
+ }
255
+ logger.debug(`Visit functions phase completed in ${(performance.now() - startFunctions).toFixed(0)}ms`);
256
+ // Transport sweep: add all transports/wirings
242
257
  const startRoutes = performance.now();
243
258
  for (const sourceFile of sourceFiles) {
244
259
  const sourceOptions = { ...options, sourceFile };
package/dist/types.d.ts CHANGED
@@ -196,6 +196,7 @@ export type InspectorFilters = {
196
196
  target?: Array<'serverless' | 'server'>;
197
197
  excludeTarget?: Array<'serverless' | 'server'>;
198
198
  serverlessIncompatible?: string[];
199
+ defaultTarget?: 'serverless' | 'server';
199
200
  };
200
201
  export type AddonConfig = {
201
202
  package: string;
@@ -220,6 +221,13 @@ export type InspectorOptions = Partial<{
220
221
  schema?: {
221
222
  additionalProperties?: boolean;
222
223
  };
224
+ /**
225
+ * Directory for the on-disk TS-schema cache. When set, generated TS schemas
226
+ * are persisted here keyed by a hash of the custom-types content, so a warm
227
+ * `pikku all` whose function types are unchanged skips ts-json-schema-generator
228
+ * entirely (the single largest cold-run cost). Omit to disable disk caching.
229
+ */
230
+ cacheDir?: string;
223
231
  };
224
232
  openAPI: {
225
233
  additionalInfo: OpenAPISpecInfo;
@@ -227,6 +235,12 @@ export type InspectorOptions = Partial<{
227
235
  tags: string[];
228
236
  manifest: VersionManifest;
229
237
  oldProgram: ts.Program | undefined;
238
+ /**
239
+ * Run the data-classification leak scan (Private/Pii/Secret brands in function
240
+ * return types). Off by default — it forces return-type inference on every
241
+ * function, which is expensive. Enabled via `pikku all --security`.
242
+ */
243
+ classificationCheck: boolean;
230
244
  }>;
231
245
  export interface InspectorLogger {
232
246
  info: (message: string) => void;
@@ -1,6 +1,23 @@
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';
5
+ /**
6
+ * Deterministic placeholder id for an anonymous/unnamed pikku function or
7
+ * permission. Derived from the call expression's source location (relative path
8
+ * + start position) so `pikku all` produces byte-identical output across runs —
9
+ * a `randomUUID()` here made generated meta non-reproducible. Still `__temp_`
10
+ * prefixed so downstream resolution (which keys off that prefix) is unchanged.
11
+ */
12
+ function tempFuncId(callExpr, rootDir) {
13
+ const sourceFile = callExpr.getSourceFile();
14
+ const relPath = relative(rootDir, sourceFile.fileName);
15
+ const hash = createHash('sha1')
16
+ .update(`${relPath}:${callExpr.getStart()}`)
17
+ .digest('hex')
18
+ .slice(0, 16);
19
+ return `__temp_${hash}`;
20
+ }
4
21
  export function makeContextBasedId(wiringType, ...segments) {
5
22
  return [wiringType, ...segments].join(':');
6
23
  }
@@ -88,7 +105,7 @@ export function extractFunctionName(callExpr, checker, rootDir) {
88
105
  result.pikkuFuncId = decl.name.text;
89
106
  }
90
107
  if (!result.pikkuFuncId) {
91
- result.pikkuFuncId = `__temp_${randomUUID()}`;
108
+ result.pikkuFuncId = tempFuncId(callExpr, rootDir);
92
109
  }
93
110
  populateNameByPriority(result);
94
111
  return result;
@@ -335,7 +352,7 @@ export function extractFunctionName(callExpr, checker, rootDir) {
335
352
  result.pikkuFuncId = result.exportedName;
336
353
  }
337
354
  else {
338
- result.pikkuFuncId = `__temp_${randomUUID()}`;
355
+ result.pikkuFuncId = tempFuncId(callExpr, rootDir);
339
356
  }
340
357
  if (result.version !== null) {
341
358
  // Strip trailing VN suffix if it matches the version (e.g. createCardV1 + version:1 → createCard@v1)
@@ -256,9 +256,10 @@ export function filterInspectorState(state, filters, logger) {
256
256
  ? new Set(filters.excludeTarget)
257
257
  : null;
258
258
  const incompatible = new Set(filters.serverlessIncompatible ?? []);
259
+ const defaultTarget = filters.defaultTarget ?? 'serverless';
259
260
  keptByDeploy = new Set();
260
261
  for (const [funcId, funcMeta] of Object.entries(state.functions.meta)) {
261
- const target = resolveDeployTarget(funcMeta, incompatible, funcId);
262
+ const target = resolveDeployTarget(funcMeta, incompatible, funcId, defaultTarget);
262
263
  if (allowed && !allowed.has(target))
263
264
  continue;
264
265
  if (excluded && excluded.has(target))
@@ -761,10 +762,10 @@ export function filterInspectorState(state, filters, logger) {
761
762
  filteredState.requiredSchemas = prunedSchemas;
762
763
  }
763
764
  // Step dispatch is decided purely per-function: a workflow step runs via the
764
- // queue only when its function opts out of inline execution (inline: false).
765
- // Such a unit needs workflowService + queueService injected even though the
766
- // function itself doesn't reference them. Check the ORIGINAL graph meta
767
- // (before filtering pruned it).
765
+ // queue only when its function is marked `workflowQueued: true`. Such a unit
766
+ // needs workflowService + queueService injected even though the function
767
+ // itself doesn't reference them. Check the ORIGINAL graph meta (before
768
+ // filtering pruned it).
768
769
  const survivingFuncIds = new Set(Object.keys(filteredState.functions.meta));
769
770
  const resolveFuncId = (rpcName) => filteredState.rpc.internalMeta[rpcName] ??
770
771
  filteredState.rpc.exposedMeta[rpcName] ??
@@ -782,7 +783,7 @@ export function filterInspectorState(state, filters, logger) {
782
783
  continue;
783
784
  const funcMeta = (filteredState.functions.meta[funcId] ??
784
785
  filteredState.functions.meta[rpcName]);
785
- if (funcMeta?.inline === false) {
786
+ if (funcMeta?.workflowQueued === true) {
786
787
  filteredState.serviceAggregation.requiredServices.add('workflowService');
787
788
  filteredState.serviceAggregation.requiredServices.add('queueService');
788
789
  }
@@ -220,11 +220,18 @@ export function aggregateRequiredServices(state) {
220
220
  pikkuFuncId: `pikkuWorkflowOrchestrator:${graph.name}`,
221
221
  };
222
222
  }
223
- // Per-step queues
223
+ // Per-step queues — only for steps explicitly marked workflowQueued: true
224
224
  for (const node of Object.values(graph.nodes)) {
225
225
  if (!('rpcName' in node) || !node.rpcName)
226
226
  continue;
227
227
  const rpcName = node.rpcName;
228
+ const funcId = state.rpc?.internalMeta?.[rpcName] ??
229
+ state.rpc?.exposedMeta?.[rpcName] ??
230
+ rpcName;
231
+ const funcMeta = (state.functions.meta[funcId] ??
232
+ state.functions.meta[rpcName]);
233
+ if (funcMeta?.workflowQueued !== true)
234
+ continue;
228
235
  const stepQueueName = `wf-step-${toKebab(rpcName)}`;
229
236
  if (!state.queueWorkers.meta[stepQueueName]) {
230
237
  state.queueWorkers.meta[stepQueueName] = {
@@ -18,11 +18,12 @@ export declare class IncompatibleDeployTargetError extends Error {
18
18
  * - throw if the function explicitly declares `deploy: 'serverless'`
19
19
  * - otherwise target is 'server'
20
20
  * 2. Explicit `funcMeta.deploy: 'serverless' | 'server'`
21
- * 3. Default 'serverless'
21
+ * 3. `defaultTarget` (sourced from `pikku.config.json` →
22
+ * `deploy.defaultTarget`, falling back to 'serverless')
22
23
  *
23
24
  * Used both by the per-unit deploy analyzer (when bucketing functions
24
25
  * into deployment units) and by `filterInspectorState` (when
25
26
  * `pikku all --deploy <target>` is used to emit a target-scoped set
26
27
  * of gen files).
27
28
  */
28
- export declare function resolveDeployTarget(funcMeta: Pick<FunctionMeta, 'deploy' | 'services'>, serverlessIncompatible: Set<string>, functionName?: string): 'serverless' | 'server';
29
+ export declare function resolveDeployTarget(funcMeta: Pick<FunctionMeta, 'deploy' | 'services'>, serverlessIncompatible: Set<string>, functionName?: string, defaultTarget?: 'serverless' | 'server'): 'serverless' | 'server';
@@ -25,14 +25,15 @@ export class IncompatibleDeployTargetError extends Error {
25
25
  * - throw if the function explicitly declares `deploy: 'serverless'`
26
26
  * - otherwise target is 'server'
27
27
  * 2. Explicit `funcMeta.deploy: 'serverless' | 'server'`
28
- * 3. Default 'serverless'
28
+ * 3. `defaultTarget` (sourced from `pikku.config.json` →
29
+ * `deploy.defaultTarget`, falling back to 'serverless')
29
30
  *
30
31
  * Used both by the per-unit deploy analyzer (when bucketing functions
31
32
  * into deployment units) and by `filterInspectorState` (when
32
33
  * `pikku all --deploy <target>` is used to emit a target-scoped set
33
34
  * of gen files).
34
35
  */
35
- export function resolveDeployTarget(funcMeta, serverlessIncompatible, functionName = '<unknown>') {
36
+ export function resolveDeployTarget(funcMeta, serverlessIncompatible, functionName = '<unknown>', defaultTarget = 'serverless') {
36
37
  // Service compatibility wins over the explicit flag — a serverless
37
38
  // bundle of a function that needs (e.g.) node:fs would crash at runtime.
38
39
  const incompatibleHits = [];
@@ -52,5 +53,5 @@ export function resolveDeployTarget(funcMeta, serverlessIncompatible, functionNa
52
53
  return 'server';
53
54
  if (funcMeta.deploy === 'serverless')
54
55
  return 'serverless';
55
- return 'serverless';
56
+ return defaultTarget;
56
57
  }
@@ -6,4 +6,5 @@ export declare function generateAllSchemas(logger: InspectorLogger, config: {
6
6
  schema?: {
7
7
  additionalProperties?: boolean;
8
8
  };
9
+ cacheDir?: string;
9
10
  }, state: InspectorState): Promise<Record<string, JSONValue>>;
@@ -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';
@@ -55,6 +57,61 @@ let cachedParsedConfig;
55
57
  let cachedTsconfigPath;
56
58
  let cachedCustomTypesContent;
57
59
  let cachedTSSchemas;
60
+ const SCHEMA_CACHE_VERSION = 1;
61
+ // This package's own version — folded into the cache key so that upgrading
62
+ // @pikku/inspector (the channel through which a schema-format change ships)
63
+ // auto-invalidates every on-disk cache, without relying on someone remembering
64
+ // to bump SCHEMA_CACHE_VERSION. Read once; falls back to the constant if the
65
+ // package.json can't be located (e.g. an unexpected bundling layout).
66
+ const inspectorVersion = (() => {
67
+ try {
68
+ const pkgUrl = new URL('../../package.json', import.meta.url);
69
+ const pkg = JSON.parse(readFileSync(pkgUrl, 'utf-8'));
70
+ return typeof pkg.version === 'string' ? pkg.version : `v${SCHEMA_CACHE_VERSION}`;
71
+ }
72
+ catch {
73
+ return `v${SCHEMA_CACHE_VERSION}`;
74
+ }
75
+ })();
76
+ // Key the TS-schema cache on everything that affects its output: the generated
77
+ // custom-types source, the generator options that change schema shape, and the
78
+ // inspector version (schema-format changes ship with a version bump).
79
+ function tsSchemaCacheKey(customTypesContent, config) {
80
+ return createHash('sha1')
81
+ .update(`v${SCHEMA_CACHE_VERSION}\0`)
82
+ .update(`pkg:${inspectorVersion}\0`)
83
+ .update(`ap:${config.schema?.additionalProperties ? 1 : 0}\0`)
84
+ .update(`ft:${(config.schemasFromTypes ?? []).join(',')}\0`)
85
+ .update(customTypesContent)
86
+ .digest('hex');
87
+ }
88
+ function schemaCacheFile(cacheDir) {
89
+ return join(cacheDir, 'ts-schemas.json');
90
+ }
91
+ function readDiskTSSchemas(logger, cacheDir, key) {
92
+ const file = schemaCacheFile(cacheDir);
93
+ if (!existsSync(file))
94
+ return null;
95
+ try {
96
+ const parsed = JSON.parse(readFileSync(file, 'utf-8'));
97
+ if (parsed?.key === key && parsed.schemas)
98
+ return parsed.schemas;
99
+ }
100
+ catch (e) {
101
+ logger.debug(`Ignoring unreadable TS-schema cache: ${e.message}`);
102
+ }
103
+ return null;
104
+ }
105
+ function writeDiskTSSchemas(logger, cacheDir, key, schemas) {
106
+ const file = schemaCacheFile(cacheDir);
107
+ try {
108
+ mkdirSync(dirname(file), { recursive: true });
109
+ writeFileSync(file, JSON.stringify({ key, schemas }));
110
+ }
111
+ catch (e) {
112
+ logger.debug(`Failed to persist TS-schema cache: ${e.message}`);
113
+ }
114
+ }
58
115
  function createProgramWithVirtualFile(tsconfig, virtualFilePath, virtualFileContent) {
59
116
  const configPath = resolve(tsconfig);
60
117
  // Cache the parsed tsconfig — it doesn't change between runs
@@ -330,12 +387,31 @@ export async function generateAllSchemas(logger, config, state) {
330
387
  const zodSchemas = await generateZodSchemas(logger, state.schemaLookup, state.functions.typesMap);
331
388
  const requiredTypes = new Set();
332
389
  const customTypesContent = generateCustomTypes(state.functions.typesMap, requiredTypes);
390
+ // Fast path: same process, types unchanged — reuse the in-memory result.
333
391
  if (cachedTSSchemas && cachedCustomTypesContent === customTypesContent) {
334
392
  logger.debug('Reusing cached TS schemas (types unchanged)');
335
393
  return { ...cachedTSSchemas, ...zodSchemas };
336
394
  }
395
+ // Disk path: a prior `pikku all` left a cache whose key matches the current
396
+ // custom types — load it and skip ts-json-schema-generator (the dominant
397
+ // cold-run cost). Zod schemas are always regenerated (cheap, ~1ms/schema).
398
+ const cacheKey = config.cacheDir
399
+ ? tsSchemaCacheKey(customTypesContent, config)
400
+ : null;
401
+ if (config.cacheDir && cacheKey) {
402
+ const disk = readDiskTSSchemas(logger, config.cacheDir, cacheKey);
403
+ if (disk) {
404
+ logger.debug('Reusing on-disk TS schemas (types unchanged across runs)');
405
+ cachedCustomTypesContent = customTypesContent;
406
+ cachedTSSchemas = disk;
407
+ return { ...disk, ...zodSchemas };
408
+ }
409
+ }
337
410
  const tsSchemas = generateTSSchemas(logger, config.tsconfig, customTypesContent, state.functions.typesMap, state.functions.meta, state.http.meta, config.schemasFromTypes, config.schema?.additionalProperties, zodSchemas);
338
411
  cachedCustomTypesContent = customTypesContent;
339
412
  cachedTSSchemas = tsSchemas;
413
+ if (config.cacheDir && cacheKey) {
414
+ writeDiskTSSchemas(logger, config.cacheDir, cacheKey, tsSchemas);
415
+ }
340
416
  return { ...tsSchemas, ...zodSchemas };
341
417
  }
@@ -0,0 +1,20 @@
1
+ import type { WorkflowStepMeta, WorkflowPlannedStep } from '@pikku/core/workflow';
2
+ /**
3
+ * Derive the static UI plan for a DSL workflow from its extracted step tree.
4
+ *
5
+ * `plannedSteps` is the ordered list of every named step the workflow can run,
6
+ * so a frontend can render the step skeleton up front without executing the
7
+ * workflow or hand-listing steps. It is populated whenever the workflow has NO
8
+ * loops (fanout) — loops make the step COUNT runtime-dependent, so a workflow
9
+ * containing any fanout gets neither field.
10
+ *
11
+ * `deterministic` is true only when the exact executed sequence is known up
12
+ * front: a flat list of named steps with no branches/switches (which pick a
13
+ * path at runtime) and no loops. A branchy-but-loopless workflow is therefore
14
+ * `deterministic: false` but still gets `plannedSteps` (the full set of
15
+ * possible steps, in source order).
16
+ */
17
+ export declare function deriveWorkflowPlan(steps: WorkflowStepMeta[]): {
18
+ deterministic: boolean;
19
+ plannedSteps?: WorkflowPlannedStep[];
20
+ };
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Derive the static UI plan for a DSL workflow from its extracted step tree.
3
+ *
4
+ * `plannedSteps` is the ordered list of every named step the workflow can run,
5
+ * so a frontend can render the step skeleton up front without executing the
6
+ * workflow or hand-listing steps. It is populated whenever the workflow has NO
7
+ * loops (fanout) — loops make the step COUNT runtime-dependent, so a workflow
8
+ * containing any fanout gets neither field.
9
+ *
10
+ * `deterministic` is true only when the exact executed sequence is known up
11
+ * front: a flat list of named steps with no branches/switches (which pick a
12
+ * path at runtime) and no loops. A branchy-but-loopless workflow is therefore
13
+ * `deterministic: false` but still gets `plannedSteps` (the full set of
14
+ * possible steps, in source order).
15
+ */
16
+ export function deriveWorkflowPlan(steps) {
17
+ if (containsLoop(steps)) {
18
+ return { deterministic: false };
19
+ }
20
+ return {
21
+ deterministic: !containsConditional(steps),
22
+ plannedSteps: collectNamedSteps(steps),
23
+ };
24
+ }
25
+ /** Any fanout (loop) anywhere in the tree — including inside branches/switches. */
26
+ function containsLoop(steps) {
27
+ return steps.some((step) => {
28
+ switch (step.type) {
29
+ case 'fanout':
30
+ return true;
31
+ case 'branch':
32
+ return (step.branches.some((b) => containsLoop(b.steps)) ||
33
+ (step.elseSteps ? containsLoop(step.elseSteps) : false));
34
+ case 'switch':
35
+ return (step.cases.some((c) => containsLoop(c.steps)) ||
36
+ (step.defaultSteps ? containsLoop(step.defaultSteps) : false));
37
+ default:
38
+ return false;
39
+ }
40
+ });
41
+ }
42
+ /** Any branch/switch — the run takes a runtime-decided path. */
43
+ function containsConditional(steps) {
44
+ return steps.some((step) => step.type === 'branch' || step.type === 'switch');
45
+ }
46
+ /** Flatten named steps (rpc/inline/sleep/parallel children) in source order. */
47
+ function collectNamedSteps(steps) {
48
+ const planned = [];
49
+ for (const step of steps) {
50
+ switch (step.type) {
51
+ case 'rpc':
52
+ case 'inline':
53
+ case 'sleep':
54
+ planned.push({ stepName: step.stepName });
55
+ break;
56
+ case 'parallel':
57
+ for (const child of step.children) {
58
+ planned.push({ stepName: child.stepName });
59
+ }
60
+ break;
61
+ case 'branch':
62
+ for (const b of step.branches)
63
+ planned.push(...collectNamedSteps(b.steps));
64
+ if (step.elseSteps)
65
+ planned.push(...collectNamedSteps(step.elseSteps));
66
+ break;
67
+ case 'switch':
68
+ for (const c of step.cases)
69
+ planned.push(...collectNamedSteps(c.steps));
70
+ if (step.defaultSteps) {
71
+ planned.push(...collectNamedSteps(step.defaultSteps));
72
+ }
73
+ break;
74
+ // set / return / cancel / filter / arrayPredicate produce no named step
75
+ }
76
+ }
77
+ return planned;
78
+ }