@pikku/inspector 0.12.95 → 0.12.98

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 CHANGED
@@ -1,3 +1,69 @@
1
+ ## 0.12.98
2
+
3
+ ### Patch Changes
4
+
5
+ - 8ac25a8: An OAuth2 credential can set `scopeSeparator` for a provider that wants scopes joined by something other than a space (Twist wants a comma). A credential with no scopes already sent no `scope` parameter; that is now tested.
6
+ - 6606777: A webhook source can declare how its requests are signed, and the runner checks every request before `receive` runs:
7
+
8
+ ```ts
9
+ wireTriggerWebhookSource({
10
+ name: 'github',
11
+ verify: {
12
+ hmac: {
13
+ header: 'x-hub-signature-256',
14
+ prefix: 'sha256=',
15
+ algorithm: 'sha256',
16
+ encoding: 'hex',
17
+ },
18
+ },
19
+ receive: githubWebhookReceive,
20
+ })
21
+ ```
22
+
23
+ `verify` is an HMAC over the raw body, a shared token or a public-key signature in one header, or a function `(request, secret, services) => boolean` for anything else. A request is refused while the secret is unset or when the signature does not match. A request without a body reaches `receive` unchecked so handshakes still work, but it may only be answered: events from it are refused.
24
+
25
+ Declaring `verify` declares the secret's credential too, so it needs no `defineCredential`. It is a singleton string named `<source>WebhookSecret` in camelCase (`microsoft-outlook` → `microsoftOutlookWebhookSecret`, see `webhookSecretCredentialName`), or whatever `credential` names, described by `credentialDescription`.
26
+
27
+ `@pikku/core/hmac` gains `hmacDigest`, `verifyHmacSignature` and `verifyPublicKeySignature`. `WebhookSigningSecret` is deprecated.
28
+
29
+ - Updated dependencies [d327fa5]
30
+ - Updated dependencies [942ebdd]
31
+ - Updated dependencies [8ac25a8]
32
+ - Updated dependencies [6606777]
33
+ - @pikku/core@0.12.132
34
+
35
+ ## 0.12.97
36
+
37
+ ### Patch Changes
38
+
39
+ - 26dbfc0: A `func` written inline in a `wireCLI` command now registers its function metadata under `cli:<program>:<command path>`, with `sessionless` taken from the helper it was built with. Before, the command pointed at an id nothing had registered. An inline channel `onMessage` still fails the build with the named "No function metadata found" error, and a test now pins that.
40
+ - 2dcc651: The inspector finds a project's source on Windows: its root directory is compared with TypeScript's forward-slash file names, not the backslash path `path.resolve` returns.
41
+ - Updated dependencies [58cccc1]
42
+ - Updated dependencies [a26c60e]
43
+ - @pikku/core@0.12.131
44
+
45
+ ## 0.12.96
46
+
47
+ ### Patch Changes
48
+
49
+ - dfcd351: The deploy planner now binds a unit to the units of the functions its functions call with `rpc.invoke('name')` / `rpc.remote('name')`. Before, a caller and callee split into different units (e.g. a no-service function in `svc-base` calling a DB function in `svc-kysely`) got no service binding and failed on the deployed stage with "No service binding for function". The inspector records literal RPC names per function as `invokes` (single, double or substitution-free template quotes; `rpc!`, `wire.rpc`) and warns on a computed name, which the planner cannot see (#1883).
50
+ - cf40182: Deployed units that call `rpc.startWorkflow('x')` now get x's meta, so the call no longer fails with `WorkflowNotFoundError`. The inspector records `startsWorkflows` for literal `rpc.startWorkflow(...)` calls. It warns when a handler computes the workflow name or passes `rpc` to a helper, because the planner cannot see those calls.
51
+
52
+ With workflow queues, the deploy planner gives a starter unit the workflow meta and orchestrator queue meta only (new `--workflowMeta` filter), plus `workflow-state` and `queue` services. Without queues the start runs inline, so the whole workflow is bundled. Core's `startWorkflow` now requires the workflow registration only for inline runs; queued runs need only the meta.
53
+
54
+ - f817f1c: Schema generation no longer re-walks every source file for each type. ts-json-schema-generator looks each root type up by name by scanning the whole program, so a project with 677 types scanned it 677 times. The inspector now builds that name index once per program. On the e2e project, TS schema generation drops from 8.7s to 0.1s and a cold `pikku all` from 38s to 8s, with identical output.
55
+ - 5bce779: Webhook trigger sources are off until someone turns them on, and addons declare their own.
56
+
57
+ An addon calls `wireTriggerWebhookSource` in its own package, and an app that wires the addon gets the source (its route included) without declaring it: named and routed after the addon's namespace, so two instances get one each. A source the app declares under the same name wins.
58
+
59
+ Every source now has an `enabled` switch in the `triggerSourceStore`, off by default. `reconcileTriggerSources` registers only enabled sources and records each one's `baseUrl` and `labelPrefix`; a disabled source's route answers 404 without running `receive`. New in core: `enableTriggerSource` registers a source with its provider and `disableTriggerSource` stops it receiving, then tears it down, both at the recorded address unless one is given. The admin addon exposes them as `triggerSourceEnable` and `triggerSourceDisable`. The `pikkuTriggerSource` table gains `enabled`, `baseUrl` and `labelPrefix`: run `pikku db generate` for the migration.
60
+
61
+ - Updated dependencies [dfcd351]
62
+ - Updated dependencies [cf40182]
63
+ - Updated dependencies [5bce779]
64
+ - Updated dependencies [5bce779]
65
+ - @pikku/core@0.12.130
66
+
1
67
  ## 0.12.95
2
68
 
3
69
  ### Patch Changes
@@ -3,6 +3,7 @@ import { extractFunctionName, makeContextBasedId, } from '../utils/extract-funct
3
3
  import { resolveCoreType } from '../utils/resolve-core-type.js';
4
4
  import { resolveMiddleware } from '../utils/middleware.js';
5
5
  import { resolveFunctionMeta } from '../utils/resolve-function-meta.js';
6
+ import { ensureInlineWiringFunction } from '../utils/ensure-function-metadata.js';
6
7
  import { extractWireNames } from '../utils/post-process.js';
7
8
  import { getPropertyValue } from '../utils/get-property-value.js';
8
9
  import { resolveIdentifier } from '../utils/resolve-identifier.js';
@@ -253,9 +254,11 @@ function processCommand(logger, inspectorState, options, name, node, sourceFile,
253
254
  }
254
255
  }
255
256
  else {
256
- pikkuFuncId = extractFunctionName(prop.initializer, typeChecker, inspectorState.rootDir).pikkuFuncId;
257
+ const extracted = extractFunctionName(prop.initializer, typeChecker, inspectorState.rootDir);
258
+ pikkuFuncId = extracted.pikkuFuncId;
257
259
  if (pikkuFuncId.startsWith('__temp_')) {
258
260
  pikkuFuncId = makeContextBasedId('cli', programName, ...fullPath);
261
+ ensureInlineWiringFunction(inspectorState, pikkuFuncId, fullPath.join(' '), prop.initializer, typeChecker, extracted.isHelper);
259
262
  }
260
263
  meta.pikkuFuncId = pikkuFuncId;
261
264
  const cliPackageName = ts.isIdentifier(prop.initializer)
@@ -81,6 +81,7 @@ export const addCredential = (logger, node, checker, state, _options) => {
81
81
  const authorizationUrl = getPropertyValue(oauth2Obj, 'authorizationUrl');
82
82
  const tokenUrl = getPropertyValue(oauth2Obj, 'tokenUrl');
83
83
  const scopes = getArrayPropertyValue(oauth2Obj, 'scopes');
84
+ const scopeSeparator = getPropertyValue(oauth2Obj, 'scopeSeparator');
84
85
  const pkce = getPropertyValue(oauth2Obj, 'pkce');
85
86
  const additionalParams = getRecordPropertyValue(oauth2Obj, 'additionalParams');
86
87
  if (appCredentialSecretId && authorizationUrl && tokenUrl && scopes) {
@@ -90,6 +91,7 @@ export const addCredential = (logger, node, checker, state, _options) => {
90
91
  authorizationUrl,
91
92
  tokenUrl,
92
93
  scopes,
94
+ scopeSeparator: scopeSeparator || undefined,
93
95
  pkce: pkce || undefined,
94
96
  additionalParams: additionalParams || undefined,
95
97
  };
@@ -3,6 +3,8 @@ import { detectSchemaVendorOrError } from '../utils/detect-schema-vendor.js';
3
3
  import { extractFunctionName, funcIdToTypeName, } from '../utils/extract-function-name.js';
4
4
  import { extractFunctionNode } from '../utils/extract-function-node.js';
5
5
  import { extractUsedWires } from '../utils/extract-services.js';
6
+ import { collectInvokedRpcNames } from './add-rpc-invocations.js';
7
+ import { collectStartedWorkflows } from './collect-started-workflows.js';
6
8
  import { formatVersionedId, parseVersionedId } from '@pikku/core/utils';
7
9
  /** Binding keys a `pikkuScenarioStep` may declare, in report order. */
8
10
  const SCENARIO_SURFACE_KEYS = [
@@ -720,6 +722,10 @@ export const addFunctions = (logger, node, checker, state, options) => {
720
722
  }
721
723
  }
722
724
  const wires = extractUsedWires(handler, 2);
725
+ const invoked = collectInvokedRpcNames(handler.body);
726
+ for (const call of invoked.dynamic) {
727
+ logger.warn(`• ${pikkuFuncId} calls ${call} with a computed name — the deploy planner cannot bind its unit to the callee's, so the call fails with "No service binding" if the two are deployed apart. Use a literal RPC name.`);
728
+ }
723
729
  // --- Generics → ts.Type[], unwrapped from Promise ---
724
730
  const genericTypes = (typeArguments ?? [])
725
731
  .map((tn) => checker.getTypeFromTypeNode(tn))
@@ -986,6 +992,17 @@ export const addFunctions = (logger, node, checker, state, options) => {
986
992
  bodyStart: lineOf(handlerBody.getStart(handlerSourceFile)),
987
993
  bodyEnd: lineOf(handlerBody.getEnd()),
988
994
  };
995
+ const started = collectStartedWorkflows(handler.body);
996
+ // Generated scaffolds (the workflow start/run routes) take the name from the
997
+ // request by design, and are served by units that have every workflow.
998
+ const handlerFile = handler.getSourceFile().fileName;
999
+ const warnOnStarts = !/\.gen\.[cm]?[jt]s$/.test(handlerFile);
1000
+ for (const call of warnOnStarts ? started.dynamic : []) {
1001
+ logger.warn(`• ${pikkuFuncId} calls ${call} with a computed name — the deploy planner cannot bundle that workflow's meta into ${pikkuFuncId}'s unit, so the deployed call fails with "Workflow not found" unless the unit has it for another reason. Use a literal workflow name.`);
1002
+ }
1003
+ if (warnOnStarts && started.rpcHandoffs.length > 0) {
1004
+ logger.warn(`• ${pikkuFuncId} passes rpc to ${started.rpcHandoffs.join(', ')} — rpc.startWorkflow / rpc.invoke calls made there are invisible to the deploy planner, so their targets are not bundled into or bound to ${pikkuFuncId}'s unit. Make those calls in the handler itself.`);
1005
+ }
989
1006
  state.functions.meta[pikkuFuncId] = {
990
1007
  pikkuFuncId,
991
1008
  functionType: 'user',
@@ -994,6 +1011,7 @@ export const addFunctions = (logger, node, checker, state, options) => {
994
1011
  name,
995
1012
  services,
996
1013
  wires: wires.wires.length > 0 || !wires.optimized ? wires : undefined,
1014
+ invokes: invoked.names.length > 0 ? invoked.names : undefined,
997
1015
  inputSchemaName: inputNames[0] ?? null,
998
1016
  outputSchemaName: outputNames[0] ?? null,
999
1017
  inputs: inputNames.filter((n) => n !== 'void') ?? null,
@@ -1037,6 +1055,7 @@ export const addFunctions = (logger, node, checker, state, options) => {
1037
1055
  bodySourceFile,
1038
1056
  exportedName: exportedName || undefined,
1039
1057
  ...bodySpan,
1058
+ startsWorkflows: started.names.length > 0 ? started.names : undefined,
1040
1059
  };
1041
1060
  if (handlerHasDynamicImport(handler)) {
1042
1061
  state.functions.dynamicImportIds.add(pikkuFuncId);
@@ -1,5 +1,22 @@
1
1
  import * as ts from 'typescript';
2
2
  import type { InspectorState, InspectorLogger } from '../types.js';
3
+ /**
4
+ * The RPC names a function handler calls through `rpc.invoke(...)` /
5
+ * `rpc.remote(...)`, read from its own body.
6
+ *
7
+ * This is what the deploy planner uses to bind a unit to the units its
8
+ * functions call: without it, a caller and callee split into different units
9
+ * type-check, work in a single process, and fail on the deployed stage with
10
+ * "No service binding for function". `dynamic` holds the source text of calls
11
+ * whose name is not a literal — those cannot be planned for.
12
+ *
13
+ * Only the handler's own body is read. A call made from a helper defined
14
+ * elsewhere, with `rpc` passed in, is not seen.
15
+ */
16
+ export declare function collectInvokedRpcNames(handler: ts.Node): {
17
+ names: string[];
18
+ dynamic: string[];
19
+ };
3
20
  /**
4
21
  * Scan for rpc.invoke() calls to track which functions are actually being invoked
5
22
  * Also detects addon usage via:
@@ -32,6 +32,63 @@ function invocationReceiver(node) {
32
32
  }
33
33
  return inner;
34
34
  }
35
+ /** `rpc`, or anything ending in `.rpc` (`wire.rpc`), seen through `!` and parens. */
36
+ function isRpcReceiver(node) {
37
+ const receiver = invocationReceiver(node);
38
+ return ts.isIdentifier(receiver)
39
+ ? receiver.text === 'rpc'
40
+ : ts.isPropertyAccessExpression(receiver) && receiver.name.text === 'rpc';
41
+ }
42
+ /**
43
+ * Methods on `rpc` that call another function by name and, when that function
44
+ * is not bundled in the caller's deploy unit, cross to another unit through the
45
+ * `DeploymentService`.
46
+ */
47
+ const CROSS_UNIT_RPC_METHODS = new Set(['invoke', 'remote']);
48
+ /** The name argument when it is statically known: `'x'`, `"x"` or `` `x` ``. */
49
+ function literalRpcName(arg) {
50
+ if (ts.isStringLiteral(arg) || ts.isNoSubstitutionTemplateLiteral(arg)) {
51
+ return arg.text;
52
+ }
53
+ return undefined;
54
+ }
55
+ /**
56
+ * The RPC names a function handler calls through `rpc.invoke(...)` /
57
+ * `rpc.remote(...)`, read from its own body.
58
+ *
59
+ * This is what the deploy planner uses to bind a unit to the units its
60
+ * functions call: without it, a caller and callee split into different units
61
+ * type-check, work in a single process, and fail on the deployed stage with
62
+ * "No service binding for function". `dynamic` holds the source text of calls
63
+ * whose name is not a literal — those cannot be planned for.
64
+ *
65
+ * Only the handler's own body is read. A call made from a helper defined
66
+ * elsewhere, with `rpc` passed in, is not seen.
67
+ */
68
+ export function collectInvokedRpcNames(handler) {
69
+ const names = new Set();
70
+ const dynamic = [];
71
+ const visit = (node) => {
72
+ if (ts.isCallExpression(node) &&
73
+ ts.isPropertyAccessExpression(node.expression) &&
74
+ CROSS_UNIT_RPC_METHODS.has(node.expression.name.text) &&
75
+ isRpcReceiver(node.expression.expression)) {
76
+ const [firstArg] = node.arguments;
77
+ if (firstArg) {
78
+ const name = literalRpcName(firstArg);
79
+ if (name !== undefined) {
80
+ names.add(name);
81
+ }
82
+ else {
83
+ dynamic.push(node.getText());
84
+ }
85
+ }
86
+ }
87
+ ts.forEachChild(node, visit);
88
+ };
89
+ visit(handler);
90
+ return { names: [...names].sort(), dynamic };
91
+ }
35
92
  /**
36
93
  * Helper to extract namespace from a namespaced function reference like 'ext:hello'
37
94
  */
@@ -152,8 +209,9 @@ export function addRPCInvocations(node, state, logger) {
152
209
  }
153
210
  const [firstArg] = args;
154
211
  if (firstArg) {
155
- if (ts.isStringLiteral(firstArg)) {
156
- const functionRef = firstArg.text;
212
+ const literalName = literalRpcName(firstArg);
213
+ if (literalName !== undefined) {
214
+ const functionRef = literalName;
157
215
  logger.debug(`• Found RPC invocation: ${functionRef}`);
158
216
  state.rpc.invokedFunctions.add(functionRef);
159
217
  let byFile = state.rpc.invokedFunctionsByFile.get(sourceFileName);
@@ -170,10 +228,9 @@ export function addRPCInvocations(node, state, logger) {
170
228
  }
171
229
  }
172
230
  // Handle template literals like `function-${name}`
173
- else if (ts.isTemplateExpression(firstArg) ||
174
- ts.isNoSubstitutionTemplateLiteral(firstArg)) {
231
+ else if (ts.isTemplateExpression(firstArg)) {
175
232
  logger.warn(`• Found dynamic RPC invocation: ${firstArg.getText()}`);
176
- logger.warn(`\tYou can only use string literals for RPC function names, with ' or " and not \``);
233
+ logger.warn(`\tYou can only use literal RPC function names, without \${...} substitutions`);
177
234
  }
178
235
  }
179
236
  }
@@ -8,6 +8,7 @@ import { extractWireNames } from '../utils/post-process.js';
8
8
  import { resolveAddonName } from '../utils/resolve-addon-package.js';
9
9
  import { resolveFunctionMeta } from '../utils/resolve-function-meta.js';
10
10
  import { ErrorCode } from '../error-codes.js';
11
+ import { webhookSecretCredentialName } from '@pikku/core/trigger';
11
12
  const WEBHOOK_SOURCE_STEPS = ['receive', 'check', 'setup', 'teardown'];
12
13
  export const addTrigger = (logger, node, checker, state, options) => {
13
14
  if (!ts.isCallExpression(node)) {
@@ -161,6 +162,21 @@ const addWireTriggerWebhookSource = (logger, node, checker, state, firstArg) =>
161
162
  };
162
163
  state.triggers.webhookSourceMeta[name] = meta;
163
164
  state.triggers.files.add(node.getSourceFile().fileName);
165
+ if (!getPropertyAssignmentInitializer(obj, 'verify', false, checker))
166
+ return;
167
+ const sourceFile = node.getSourceFile().fileName;
168
+ const credential = getPropertyValue(obj, 'credential') ??
169
+ webhookSecretCredentialName(name);
170
+ state.credentials.files.add(sourceFile);
171
+ state.credentials.definitions.push({
172
+ name: credential,
173
+ displayName: `${name} webhook secret`,
174
+ description: getPropertyValue(obj, 'credentialDescription') ??
175
+ undefined,
176
+ type: 'singleton',
177
+ schema: { type: 'string' },
178
+ sourceFile,
179
+ });
164
180
  };
165
181
  /**
166
182
  * A trigger named `<source>:<event>` must name an event its webhook source
@@ -0,0 +1,26 @@
1
+ import * as ts from 'typescript';
2
+ export type StartedWorkflows = {
3
+ /** Workflow names passed as a literal to `rpc.startWorkflow(...)`. */
4
+ names: string[];
5
+ /** Source text of `rpc.startWorkflow(...)` calls whose name is computed. */
6
+ dynamic: string[];
7
+ /**
8
+ * Plain functions the handler passes `rpc` to — calls made inside them are
9
+ * not seen. Methods (`workflowService.runToCompletion(..., rpc)`) are left
10
+ * out: they are services, not the app's own helpers.
11
+ */
12
+ rpcHandoffs: string[];
13
+ };
14
+ /**
15
+ * The workflows a function handler starts through `rpc.startWorkflow(...)`,
16
+ * read from its own body.
17
+ *
18
+ * `startWorkflow` looks the workflow up by name in the process that calls it,
19
+ * so a deployed unit that starts a workflow needs that workflow's meta bundled
20
+ * in. The deploy planner does that from these names.
21
+ *
22
+ * Only the handler's own body is read. When the handler passes `rpc` on to a
23
+ * helper, whatever the helper starts is invisible here; those callees are
24
+ * returned in `rpcHandoffs` so the caller can say so.
25
+ */
26
+ export declare function collectStartedWorkflows(handler: ts.Node): StartedWorkflows;
@@ -0,0 +1,72 @@
1
+ import * as ts from 'typescript';
2
+ /** `x` seen through `x!` and `(x)`. */
3
+ function unwrap(node) {
4
+ let inner = node;
5
+ while (ts.isNonNullExpression(inner) || ts.isParenthesizedExpression(inner)) {
6
+ inner = inner.expression;
7
+ }
8
+ return inner;
9
+ }
10
+ /** `rpc`, or anything ending in `.rpc` (`wire.rpc`), seen through `!` and parens. */
11
+ function isRpcExpression(node) {
12
+ const inner = unwrap(node);
13
+ return ts.isIdentifier(inner)
14
+ ? inner.text === 'rpc'
15
+ : ts.isPropertyAccessExpression(inner) && inner.name.text === 'rpc';
16
+ }
17
+ /** An argument that hands `rpc` over: `rpc`, `wire.rpc`, `{ rpc }`, `{ rpc: wire.rpc }`. */
18
+ function passesRpc(arg) {
19
+ if (isRpcExpression(arg))
20
+ return true;
21
+ const inner = unwrap(arg);
22
+ if (!ts.isObjectLiteralExpression(inner))
23
+ return false;
24
+ return inner.properties.some((p) => (ts.isShorthandPropertyAssignment(p) && p.name.text === 'rpc') ||
25
+ (ts.isPropertyAssignment(p) && isRpcExpression(p.initializer)));
26
+ }
27
+ /**
28
+ * The workflows a function handler starts through `rpc.startWorkflow(...)`,
29
+ * read from its own body.
30
+ *
31
+ * `startWorkflow` looks the workflow up by name in the process that calls it,
32
+ * so a deployed unit that starts a workflow needs that workflow's meta bundled
33
+ * in. The deploy planner does that from these names.
34
+ *
35
+ * Only the handler's own body is read. When the handler passes `rpc` on to a
36
+ * helper, whatever the helper starts is invisible here; those callees are
37
+ * returned in `rpcHandoffs` so the caller can say so.
38
+ */
39
+ export function collectStartedWorkflows(handler) {
40
+ const names = new Set();
41
+ const dynamic = [];
42
+ const rpcHandoffs = new Set();
43
+ const visit = (node) => {
44
+ if (ts.isCallExpression(node)) {
45
+ const callee = node.expression;
46
+ const isRpcMethod = ts.isPropertyAccessExpression(callee) &&
47
+ isRpcExpression(callee.expression);
48
+ if (isRpcMethod && callee.name.text === 'startWorkflow') {
49
+ const [firstArg] = node.arguments;
50
+ if (firstArg) {
51
+ if (ts.isStringLiteral(firstArg) ||
52
+ ts.isNoSubstitutionTemplateLiteral(firstArg)) {
53
+ names.add(firstArg.text);
54
+ }
55
+ else {
56
+ dynamic.push(node.getText());
57
+ }
58
+ }
59
+ }
60
+ else if (ts.isIdentifier(callee) && node.arguments.some(passesRpc)) {
61
+ rpcHandoffs.add(callee.text);
62
+ }
63
+ }
64
+ ts.forEachChild(node, visit);
65
+ };
66
+ visit(handler);
67
+ return {
68
+ names: [...names].sort(),
69
+ dynamic,
70
+ rpcHandoffs: [...rpcHandoffs].sort(),
71
+ };
72
+ }
package/dist/inspector.js CHANGED
@@ -7,6 +7,7 @@ import { getFilesAndMethods } from './utils/get-files-and-methods.js';
7
7
  import { findCommonAncestor } from './utils/find-root-dir.js';
8
8
  import { createSourceFileCacheHost } from './source-file-cache.js';
9
9
  import { createNestedProjectFilter } from './utils/nested-project-filter.js';
10
+ import { toProgramPath } from './utils/program-path.js';
10
11
  import { aggregateRequiredServices, stampAuthHandlerServices, validateAgentModels, validateAgentToolReferences, validateAgentScorerReferences, validateSecretOverrides, validateVariableOverrides, validateCredentialOverrides, validateRemoteAddonDependencies, validateRemoteAddonAuth, validateScopeReferences, validateSystemRoleScopes, validatePersonaRoles, computeResolvedIOTypes, computeMiddlewareGroupsMeta, computePermissionsGroupsMeta, computeRequiredSchemas, validateNoSecretAliasServices, registerDerivedOAuth2AppSecrets, validateSecretUsage, computeDiagnostics, validateSchemaReferences, validateSchemaWiringSeparation, validateScenarioServices, validateScenarioSteps, validateScenarioFeatures, validateWorkflowGraphAddons, } from './utils/post-process.js';
11
12
  import { validateExposedFunctionsGated } from './utils/validate-exposed-functions-gated.js';
12
13
  import { validateTagsResolveToMiddleware } from './utils/validate-tags-resolve-to-middleware.js';
@@ -293,7 +294,7 @@ export const inspect = async (logger, routeFiles, options = {}) => {
293
294
  const checker = program.getTypeChecker();
294
295
  logger.debug(`Got type checker in ${(performance.now() - startChecker).toFixed(2)}ms`);
295
296
  // Use provided rootDir or infer from source files
296
- const rootDir = options.rootDir || findCommonAncestor(normalizedRouteFiles);
297
+ const rootDir = toProgramPath(options.rootDir || findCommonAncestor(normalizedRouteFiles));
297
298
  const startSourceFiles = performance.now();
298
299
  // node_modules under rootDir (e.g. a locally-installed addon) is a
299
300
  // dependency, not project source — scanning it double-counts the addon's
package/dist/types.d.ts CHANGED
@@ -227,6 +227,7 @@ export type InspectorFilters = {
227
227
  excludeTarget?: Array<'serverless' | 'server'>;
228
228
  serverlessIncompatible?: string[];
229
229
  defaultTarget?: 'serverless' | 'server';
230
+ workflowMeta?: string[];
230
231
  };
231
232
  export type AddonConfig = {
232
233
  package: string;
@@ -831,6 +831,54 @@ export function filterInspectorState(state, filters, logger) {
831
831
  }
832
832
  }
833
833
  }
834
+ // Workflows asked for by meta only — a unit that `rpc.startWorkflow`s one the
835
+ // orchestrator unit runs. The run is created from the meta and handed to the
836
+ // orchestrator queue, so the meta (and the workflow function's meta, which
837
+ // the generated workflow map types the start from) is restored, while the
838
+ // registration files stay out: bundling them would pull the workflow
839
+ // function and everything it imports into the unit.
840
+ const fullWorkflowNames = new Set(filters.names ?? []);
841
+ for (const name of filters.workflowMeta ?? []) {
842
+ const graphMeta = state.workflows?.graphMeta?.[name];
843
+ const workflowMeta = state.workflows?.meta?.[name];
844
+ if (!graphMeta && !workflowMeta)
845
+ continue;
846
+ const pikkuFuncId = graphMeta?.pikkuFuncId ?? workflowMeta?.pikkuFuncId;
847
+ if (graphMeta && !filteredState.workflows.graphMeta[name]) {
848
+ filteredState.workflows.graphMeta[name] = JSON.parse(JSON.stringify(graphMeta));
849
+ }
850
+ if (workflowMeta && !filteredState.workflows.meta[name]) {
851
+ filteredState.workflows.meta[name] = JSON.parse(JSON.stringify(workflowMeta));
852
+ }
853
+ const funcMeta = pikkuFuncId ? state.functions.meta[pikkuFuncId] : undefined;
854
+ if (pikkuFuncId && funcMeta && !filteredState.functions.meta[pikkuFuncId]) {
855
+ filteredState.functions.meta[pikkuFuncId] = funcMeta;
856
+ }
857
+ // The orchestrator queue's meta routes the start to the workflow's own
858
+ // queue instead of the shared fallback; its consumer is core's synthetic
859
+ // orchestrator, so no registration comes with it.
860
+ const orchestratorQueue = `wf-orchestrator-${name
861
+ .replace(/([a-z0-9])([A-Z])/g, '$1-$2')
862
+ .replace(/([A-Z])([A-Z][a-z])/g, '$1-$2')
863
+ .toLowerCase()}`;
864
+ const orchestratorMeta = state.queueWorkers?.meta?.[orchestratorQueue];
865
+ if (orchestratorMeta &&
866
+ !filteredState.queueWorkers.meta[orchestratorQueue]) {
867
+ filteredState.queueWorkers.meta[orchestratorQueue] = JSON.parse(JSON.stringify(orchestratorMeta));
868
+ }
869
+ const keepsRegistration = fullWorkflowNames.has(name) ||
870
+ (pikkuFuncId !== undefined &&
871
+ filteredState.serviceAggregation.usedFunctions.has(pikkuFuncId));
872
+ if (!keepsRegistration) {
873
+ for (const key of [name, pikkuFuncId]) {
874
+ if (!key)
875
+ continue;
876
+ filteredState.workflows.files.delete(key);
877
+ filteredState.workflows.graphFiles.delete(key);
878
+ }
879
+ }
880
+ filteredState.serviceAggregation.requiredServices.add('workflowService');
881
+ }
834
882
  // Recompute requiredSchemas based on pruned functions.meta
835
883
  if (filteredState.serviceAggregation.usedFunctions.size > 0) {
836
884
  const prunedSchemas = new Set();
@@ -1,5 +1,13 @@
1
1
  import type { InspectorState, InspectorLogger } from '../types.js';
2
+ import type { WebhookSourcesMeta } from '@pikku/core/trigger';
2
3
  export declare const describeMissingAddonMeta: (namespace: string, packageName: string, dirs: string[], declFile?: string) => string;
4
+ /**
5
+ * The webhook sources an addon declares, as the app mounts them. The source is
6
+ * named after the addon's namespace, so two instances of one addon get a
7
+ * route each; an addon declaring several suffixes each with its own name.
8
+ * Every source starts off: the app turns one on at runtime.
9
+ */
10
+ export declare const namespaceAddonWebhookSources: (sources: WebhookSourcesMeta, namespace: string) => WebhookSourcesMeta;
3
11
  /**
4
12
  * After the setup sweep discovers wireAddon() declarations, load each addon
5
13
  * package's function metadata so that wiring handlers (channels, HTTP routes,
@@ -151,6 +151,31 @@ const registerAddonTypes = (state, metaPath, meta) => {
151
151
  }
152
152
  }
153
153
  };
154
+ /**
155
+ * The webhook sources an addon declares, as the app mounts them. The source is
156
+ * named after the addon's namespace, so two instances of one addon get a
157
+ * route each; an addon declaring several suffixes each with its own name.
158
+ * Every source starts off: the app turns one on at runtime.
159
+ */
160
+ export const namespaceAddonWebhookSources = (sources, namespace) => {
161
+ const declared = Object.values(sources);
162
+ const namespaced = {};
163
+ for (const source of declared) {
164
+ const name = declared.length === 1 ? namespace : `${namespace}-${source.name}`;
165
+ const meta = {
166
+ ...source,
167
+ name,
168
+ route: `/webhooks/${name}`,
169
+ };
170
+ for (const step of ['receive', 'check', 'setup', 'teardown']) {
171
+ const funcId = source[step];
172
+ if (funcId && !funcId.includes(':'))
173
+ meta[step] = `${namespace}:${funcId}`;
174
+ }
175
+ namespaced[name] = meta;
176
+ }
177
+ return namespaced;
178
+ };
154
179
  /**
155
180
  * After the setup sweep discovers wireAddon() declarations, load each addon
156
181
  * package's function metadata so that wiring handlers (channels, HTTP routes,
@@ -366,6 +391,28 @@ export async function loadAddonFunctionsMeta(logger, state) {
366
391
  }
367
392
  catch { }
368
393
  }
394
+ try {
395
+ const webhookSourcesPath = require.resolve(`${decl.package}/.pikku/webhooks/pikku-webhook-sources-meta.gen.json`);
396
+ const sources = namespaceAddonWebhookSources(JSON.parse(await readFile(webhookSourcesPath, 'utf-8')), namespace);
397
+ for (const [name, source] of Object.entries(sources)) {
398
+ if (state.triggers.webhookSourceMeta[name])
399
+ continue;
400
+ state.triggers.webhookSourceMeta[name] = source;
401
+ for (const step of [
402
+ 'receive',
403
+ 'check',
404
+ 'setup',
405
+ 'teardown',
406
+ ]) {
407
+ const funcId = source[step];
408
+ if (funcId)
409
+ state.serviceAggregation.usedFunctions.add(funcId);
410
+ }
411
+ }
412
+ }
413
+ catch {
414
+ // No addon webhook sources
415
+ }
369
416
  try {
370
417
  const httpContractsPath = require.resolve(`${decl.package}/.pikku/http/pikku-http-contracts-meta.gen.json`);
371
418
  const httpContractsRaw = await readFile(httpContractsPath, 'utf-8');
@@ -0,0 +1,7 @@
1
+ /**
2
+ * A native path in the form a TypeScript program names its files: forward
3
+ * slashes on every platform. `sourceFile.fileName` is `D:/a/app/src/x.ts` on
4
+ * Windows while `path.resolve` gives `D:\a\app`, so comparing the two by
5
+ * prefix rejects every file unless one side is converted.
6
+ */
7
+ export declare const toProgramPath: (nativePath: string, separator?: "/" | "\\") => string;
@@ -0,0 +1,8 @@
1
+ import { sep } from 'path';
2
+ /**
3
+ * A native path in the form a TypeScript program names its files: forward
4
+ * slashes on every platform. `sourceFile.fileName` is `D:/a/app/src/x.ts` on
5
+ * Windows while `path.resolve` gives `D:\a\app`, so comparing the two by
6
+ * prefix rejects every file unless one side is converted.
7
+ */
8
+ export const toProgramPath = (nativePath, separator = sep) => nativePath.split(separator).join('/');
@@ -2,7 +2,7 @@ import * as ts from 'typescript';
2
2
  import { createHash } from 'crypto';
3
3
  import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync, } from 'fs';
4
4
  import { dirname, join, resolve } from 'path';
5
- import { createGenerator, RootlessError } from 'ts-json-schema-generator';
5
+ import { createFormatter, createParser, DEFAULT_CONFIG, RootlessError, SchemaGenerator, } from 'ts-json-schema-generator';
6
6
  import { register } from 'tsx/esm/api';
7
7
  import * as z from 'zod';
8
8
  import { findZodTransform } from './find-zod-transform.js';
@@ -184,6 +184,34 @@ export function writeDiskTSSchemas(logger, cacheDir, key, schemas, deps) {
184
184
  logger.debug(`Failed to persist TS-schema cache: ${e.message}`);
185
185
  }
186
186
  }
187
+ // The base class walks every source file again for each type it looks up by
188
+ // name, which was nearly all of schema generation (8.7s for 677 types on e2e).
189
+ class IndexedSchemaGenerator extends SchemaGenerator {
190
+ projectTypes;
191
+ allTypes;
192
+ findNamedNode(fullName) {
193
+ const typeChecker = this.program.getTypeChecker();
194
+ if (!this.projectTypes) {
195
+ this.projectTypes = new Map();
196
+ this.appendTypes(this.partitionFiles().projectFiles, typeChecker, this.projectTypes);
197
+ }
198
+ const projectType = this.projectTypes.get(fullName);
199
+ if (projectType)
200
+ return projectType;
201
+ if (!this.allTypes) {
202
+ this.allTypes = new Map(this.projectTypes);
203
+ this.appendTypes(this.partitionFiles().externalFiles, typeChecker, this.allTypes);
204
+ }
205
+ const type = this.allTypes.get(fullName);
206
+ if (type)
207
+ return type;
208
+ throw new RootlessError(fullName);
209
+ }
210
+ }
211
+ const createIndexedGenerator = (program, config) => {
212
+ const completedConfig = { ...DEFAULT_CONFIG, ...config };
213
+ return new IndexedSchemaGenerator(program, createParser(program, completedConfig), createFormatter(completedConfig), completedConfig);
214
+ };
187
215
  function createProgramWithVirtualFile(logger, tsconfig, virtualFilePath, virtualFileContent) {
188
216
  const configPath = resolve(tsconfig);
189
217
  // Cache the parsed tsconfig — it doesn't change between runs
@@ -271,8 +299,7 @@ function generateTSSchemas(logger, tsconfig, customTypesContent, typesMap, funct
271
299
  logger.debug(`generateTSSchemas needed for ${uncoveredSchemas.length} types: ${uncoveredSchemas.slice(0, 3).join(', ')}${uncoveredSchemas.length > 3 ? '...' : ''}`);
272
300
  const virtualFilePath = join(dirname(resolve(tsconfig)), '__pikku_virtual_types__.ts');
273
301
  const program = createProgramWithVirtualFile(logger, tsconfig, virtualFilePath, customTypesContent);
274
- const generator = createGenerator({
275
- tsProgram: program,
302
+ const generator = createIndexedGenerator(program, {
276
303
  skipTypeCheck: true,
277
304
  topRef: false,
278
305
  discriminatorType: 'open-api',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikku/inspector",
3
- "version": "0.12.95",
3
+ "version": "0.12.98",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/pikkujs/pikku.git",
@@ -54,10 +54,10 @@
54
54
  "zod": "^4.4.3"
55
55
  },
56
56
  "peerDependencies": {
57
- "@pikku/core": "^0.12.128"
57
+ "@pikku/core": "^0.12.132"
58
58
  },
59
59
  "devDependencies": {
60
- "@pikku/core": "^0.12.128",
60
+ "@pikku/core": "^0.12.132",
61
61
  "@types/node": "^24.13.3"
62
62
  },
63
63
  "engines": {