@pikku/core 0.12.56 → 0.12.58

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,26 @@
1
+ ## 0.12.58
2
+
3
+ ### Patch Changes
4
+
5
+ - 7b17b14: Allow a workflow-graph node's `func` to reference a registered AI agent by name, dispatched as an agent run — exactly like sub-workflows. `executeGraphStep`/`executeGraphNodeInline` now check the agent registry and dispatch matching nodes via the agent-run path (`rpc.agent.run`), so the node's result is the agent's declared output and downstream nodes can `ref()` it. The generated `pikkuWorkflowGraph` wrapper widens its node-func union to also accept `keyof FlattenedWorkflowMap` and `keyof FlattenedAgentMap`, and `ref()` resolves an agent node's output keys.
6
+ - daec082: Drop Node 22 support — the minimum supported runtime is now Node 24 (LTS).
7
+
8
+ Node 22 deadlocks `pikku dev` at `loadUserBootstrap` (tsx `register()` + `require(esm)` cycle handling on node 22.12+), and Node 20 is already below our floor. The `engines.node` requirement is raised to `>=24` across all packages, matching `.nvmrc` and the CI test matrix. Closes #751.
9
+
10
+ - e0fd352: wireGateway: allow `adapter` to be a factory `(services) => GatewayAdapter | Promise<GatewayAdapter>`, resolved lazily on first inbound request (webhook/websocket) or gateway start (listener) and cached. Real platform adapters (WhatsApp Cloud API, Slack) need secrets that only exist after boot, while wireGateway runs at module load — a factory bridges that. Factory adapters register the GET verify route unconditionally since verifyWebhook can't be probed before first resolve.
11
+
12
+ ## 0.12.57
13
+
14
+ ### Patch Changes
15
+
16
+ - 60ad8cb: fix dev-server hot reload so edited AND new functions/routes apply without a restart
17
+ - `@pikku/core`: the hot reloader fed raw zod `input`/`output` schemas into the JSON-schema map, so `compileAllSchemas` threw `Failed to compile schema` on every reload and the reload aborted (only the function body sometimes swapped, half-updated). It now registers function implementations only and leaves schemas to the codegen JSON output. New function exports are registered too (previously only already-registered names were replaced). Reloads write into the startup functions map directly to avoid a race with the dev watcher's codegen-scoped state swap, and re-import via a uniquely-named sibling copy since neither Bun nor tsx bust the module cache on a `?t=` query.
18
+ - New `reloadGeneratedMeta` (exported from `@pikku/core/dev`) re-reads the regenerated wiring meta + JSON schemas into the running process so new/changed routes, RPCs, queues and agents resolve without a restart.
19
+ - `@pikku/cli`: `pikku dev` now calls `reloadGeneratedMeta` after each watch-triggered codegen pass and re-imports the changed files once fresh meta is in state, so a NEW route in a changed wiring file registers (its `wireHTTP` no longer no-ops on missing meta).
20
+ - `@pikku/schema-cfworker`: `compileSchema` recompiles when a schema's value changes (not only on first sight), so hot-reloaded schemas take effect.
21
+
22
+ - 8f5c998: Fix dev hot-reload dropping runtime-registered function/queue meta. `reloadGeneratedMeta` replaced the whole `function`/`queue` meta maps with the generated JSON, wiping entries the framework registers at service-init (the workflow orchestrator, per-workflow queue workers, and other `addFunction`'d internals that never appear in the generated files). Workflow jobs then failed with `Function meta not found: pikkuWorkflowOrchestrator`. The reload now merges over the existing maps so those internals survive.
23
+
1
24
  ## 0.12.56
2
25
 
3
26
  ### Patch Changes
@@ -1,10 +1,14 @@
1
1
  import type { Logger } from '../services/logger.js';
2
+ export * from './reload-meta.js';
2
3
  interface PikkuDevReloaderOptions {
3
4
  srcDirectories: string[];
4
5
  logger: Logger;
5
6
  pikkuDir?: string;
6
7
  }
7
- export declare function pikkuDevReloader(options: PikkuDevReloaderOptions): Promise<{
8
+ export interface PikkuDevReloaderHandle {
8
9
  close: () => void;
9
- }>;
10
- export {};
10
+ /** Re-import every file changed since the last drain (post-codegen, once
11
+ * fresh meta is in state). */
12
+ reimportPending: () => Promise<void>;
13
+ }
14
+ export declare function pikkuDevReloader(options: PikkuDevReloaderOptions): Promise<PikkuDevReloaderHandle>;
@@ -1,15 +1,14 @@
1
1
  import { watch } from 'node:fs';
2
- import { stat, readFile } from 'node:fs/promises';
3
- import { join, resolve, relative } from 'node:path';
2
+ import { stat, readFile, copyFile, rm } from 'node:fs/promises';
3
+ import { basename, dirname, join, resolve, relative } from 'node:path';
4
4
  import { pathToFileURL } from 'node:url';
5
5
  import { register } from 'tsx/esm/api';
6
6
  import { pikkuState } from '../pikku-state.js';
7
- import { addFunction } from '../function/function-runner.js';
8
7
  import { clearMiddlewareCache } from '../middleware-runner.js';
9
8
  import { clearPermissionsCache } from '../permissions.js';
10
9
  import { clearChannelMiddlewareCache } from '../wirings/channel/channel-middleware-runner.js';
11
10
  import { httpRouter } from '../wirings/http/routers/http-router.js';
12
- import { addSchema, compileAllSchemas } from '../schema.js';
11
+ export * from './reload-meta.js';
13
12
  const isFunctionConfig = (value) => {
14
13
  return (typeof value === 'object' &&
15
14
  value !== null &&
@@ -44,11 +43,31 @@ const ensureTsxRegistered = () => {
44
43
  register();
45
44
  tsxRegistered = true;
46
45
  };
46
+ let tempCounter = 0;
47
47
  const reimportModule = async (filePath, useTsx = false) => {
48
48
  try {
49
49
  if (useTsx) {
50
- ensureTsxRegistered();
51
- return await import(`${pathToFileURL(resolve(filePath)).href}?t=${Date.now()}`);
50
+ // Import a uniquely-named sibling copy: a `?t=` query does NOT bust
51
+ // the cache on either runtime (Bun keys module identity on the bare
52
+ // path; tsx's transform cache keys on the file path too), so a fresh
53
+ // path is the only reliable re-import. Same directory → identical
54
+ // resolution for relative and package-`imports` (#…) specifiers. The
55
+ // dot-prefix keeps it out of the watcher.
56
+ if (!process.versions.bun) {
57
+ // Node needs tsx's loader to import raw .ts; Bun imports it natively.
58
+ ensureTsxRegistered();
59
+ }
60
+ const abs = resolve(filePath);
61
+ const tempPath = join(dirname(abs), `.pikku-hot-${++tempCounter}-${basename(abs)}`);
62
+ await copyFile(abs, tempPath);
63
+ try {
64
+ return await import(pathToFileURL(tempPath).href);
65
+ }
66
+ finally {
67
+ await rm(tempPath, { force: true }).catch(() => {
68
+ // Best-effort temp cleanup; a leftover dotfile is watcher-ignored.
69
+ });
70
+ }
52
71
  }
53
72
  const content = await readFile(resolve(filePath), 'utf-8');
54
73
  const dataUrl = 'data:text/javascript;base64,' + Buffer.from(content).toString('base64');
@@ -62,7 +81,10 @@ const isWatchedTsFile = (filename) => {
62
81
  return (filename.endsWith('.ts') &&
63
82
  !filename.endsWith('.test.ts') &&
64
83
  !filename.endsWith('.d.ts') &&
65
- !filename.endsWith('.gen.ts'));
84
+ !filename.endsWith('.gen.ts') &&
85
+ // Hidden files: editor/sed atomic-write temps and our own hot-reload
86
+ // sibling copies must never trigger a reload of themselves.
87
+ !basename(filename).startsWith('.'));
66
88
  };
67
89
  export async function pikkuDevReloader(options) {
68
90
  const { srcDirectories, logger, pikkuDir = '.pikku' } = options;
@@ -73,6 +95,7 @@ export async function pikkuDevReloader(options) {
73
95
  const handleFileChange = async (changedTsFile) => {
74
96
  const start = Date.now();
75
97
  const reloadedNames = [];
98
+ const addedNames = [];
76
99
  const srcDir = absSrcDirs.find((d) => changedTsFile.startsWith(d));
77
100
  if (!srcDir)
78
101
  return;
@@ -87,48 +110,59 @@ export async function pikkuDevReloader(options) {
87
110
  logger.error(`Failed to import: ${relative(process.cwd(), importPath)} (keeping old code)`);
88
111
  return;
89
112
  }
90
- let schemasChanged = false;
113
+ // Register every function-config export — replacing known functions AND
114
+ // adding new ones (a brand-new function becomes callable as soon as its
115
+ // meta lands via reloadGeneratedMeta after the next codegen pass).
116
+ // Write into the map captured at startup, NOT pikkuState's current one:
117
+ // a dev-server watcher may have temporarily swapped in a codegen-scoped
118
+ // map for the same file event (runAllWithCommandState), and a write to
119
+ // that map is silently discarded when it restores the original.
120
+ // Schemas are NOT touched here: `input`/`output` hold raw zod schemas,
121
+ // while the schema map carries codegen-generated JSON schemas — mixing the
122
+ // two crashed every reload. Fresh JSON schemas arrive via
123
+ // reloadGeneratedMeta once codegen has re-emitted them.
91
124
  for (const [exportName, exportValue] of Object.entries(mod)) {
92
- if (isFunctionConfig(exportValue) && functionsMap.has(exportName)) {
93
- addFunction(exportName, exportValue);
125
+ if (!isFunctionConfig(exportValue))
126
+ continue;
127
+ const isNew = !functionsMap.has(exportName);
128
+ functionsMap.set(exportName, exportValue);
129
+ if (isNew)
130
+ addedNames.push(exportName);
131
+ else
94
132
  reloadedNames.push(exportName);
95
- if (exportValue.input) {
96
- addSchema(exportName, exportValue.input);
97
- schemasChanged = true;
98
- }
99
- if (exportValue.output) {
100
- addSchema(`${exportName}Output`, exportValue.output);
101
- schemasChanged = true;
102
- }
103
- }
104
133
  }
105
- if (reloadedNames.length > 0) {
106
- clearMiddlewareCache();
107
- clearPermissionsCache();
108
- clearChannelMiddlewareCache();
109
- httpRouter.reset();
110
- if (schemasChanged) {
111
- try {
112
- compileAllSchemas(logger);
113
- }
114
- catch (err) {
115
- const msg = err instanceof Error ? err.message : String(err);
116
- if (msg.includes('SchemaService') ||
117
- (msg.includes('schema') && msg.includes('not'))) {
118
- logger.warn('Schema recompilation skipped (no SchemaService)');
119
- }
120
- else {
121
- logger.error(`Schema recompilation failed: ${msg}`);
122
- return;
123
- }
124
- }
125
- }
134
+ // Re-importing the module re-ran its wire* side effects (wireHTTP et al
135
+ // are keyed map-sets, so re-registration replaces/adds) — reset the
136
+ // router and caches even when no function export changed, so a
137
+ // wiring-only file edit rebuilds the route matchers too.
138
+ clearMiddlewareCache();
139
+ clearPermissionsCache();
140
+ clearChannelMiddlewareCache();
141
+ httpRouter.reset();
142
+ if (reloadedNames.length > 0 || addedNames.length > 0) {
126
143
  const elapsed = Date.now() - start;
127
- logger.info(`Hot-reloaded: ${reloadedNames.join(', ')} (${elapsed}ms)`);
144
+ const parts = [];
145
+ if (reloadedNames.length > 0)
146
+ parts.push(reloadedNames.join(', '));
147
+ if (addedNames.length > 0)
148
+ parts.push(`new: ${addedNames.join(', ')}`);
149
+ logger.info(`Hot-reloaded: ${parts.join('; ')} (${elapsed}ms)`);
128
150
  }
129
151
  };
130
152
  let debounceTimer;
131
153
  const pendingChanges = new Set();
154
+ // Files re-imported since the last reimportPending() drain. A dev-server
155
+ // watcher drains this after its codegen pass so wire* registrations that
156
+ // were skipped for missing meta (a NEW route) run again with fresh meta.
157
+ const postCodegenQueue = new Set();
158
+ const safeHandleFileChange = async (file) => {
159
+ try {
160
+ await handleFileChange(file);
161
+ }
162
+ catch (err) {
163
+ logger.error(`Hot-reload error for ${relative(process.cwd(), file)}: ${err instanceof Error ? err.message : String(err)}`);
164
+ }
165
+ };
132
166
  const scheduleReload = (filePath) => {
133
167
  pendingChanges.add(filePath);
134
168
  if (debounceTimer)
@@ -137,12 +171,8 @@ export async function pikkuDevReloader(options) {
137
171
  const files = [...pendingChanges];
138
172
  pendingChanges.clear();
139
173
  for (const file of files) {
140
- try {
141
- await handleFileChange(file);
142
- }
143
- catch (err) {
144
- logger.error(`Hot-reload error for ${relative(process.cwd(), file)}: ${err instanceof Error ? err.message : String(err)}`);
145
- }
174
+ postCodegenQueue.add(file);
175
+ await safeHandleFileChange(file);
146
176
  }
147
177
  }, 50);
148
178
  };
@@ -168,5 +198,18 @@ export async function pikkuDevReloader(options) {
168
198
  watcher.close();
169
199
  }
170
200
  },
201
+ // Drain the queue of recently re-imported files and import them again.
202
+ // A dev-server watcher calls this AFTER its codegen pass has refreshed
203
+ // the generated meta (reloadGeneratedMeta): wire* registrations skip
204
+ // routes whose meta doesn't exist yet, so a wiring file changed
205
+ // alongside a NEW function only registers its new route when
206
+ // re-imported after the fresh meta has landed.
207
+ reimportPending: async () => {
208
+ const files = [...postCodegenQueue];
209
+ postCodegenQueue.clear();
210
+ for (const file of files) {
211
+ await safeHandleFileChange(file);
212
+ }
213
+ },
171
214
  };
172
215
  }
@@ -0,0 +1,24 @@
1
+ import type { Logger } from '../services/logger.js';
2
+ import type { SchemaService } from '../services/schema-service.js';
3
+ export interface ReloadGeneratedMetaOptions {
4
+ /** The project's generated output directory (the CLI's resolved outDir). */
5
+ pikkuDir: string;
6
+ logger: Logger;
7
+ /** Used to recompile validators for changed schemas; falls back to the
8
+ * schema service on the registered singleton services. */
9
+ schemaService?: SchemaService;
10
+ }
11
+ /**
12
+ * Re-reads the codegen output (wiring meta + JSON schemas) into the running
13
+ * process so new and changed functions become callable without a server
14
+ * restart. The generated `*-meta.gen.ts` files are plain
15
+ * `pikkuState(area, key, <json>)` side effects, but they cannot be
16
+ * re-imported after a dev-time codegen — the ESM cache pins both the wrapper
17
+ * and its JSON import — so this reads the JSON sources directly and applies
18
+ * the same state.
19
+ *
20
+ * Meant to be called by a dev-server watcher after each codegen pass. Routes
21
+ * registered by NEW `wireHTTP` files are not picked up (their modules were
22
+ * never imported); those still need a restart.
23
+ */
24
+ export declare function reloadGeneratedMeta(options: ReloadGeneratedMetaOptions): Promise<void>;
@@ -0,0 +1,99 @@
1
+ import { readdir, readFile } from 'node:fs/promises';
2
+ import { join, resolve } from 'node:path';
3
+ import { pikkuState } from '../pikku-state.js';
4
+ import { addSchema, compileAllSchemas } from '../schema.js';
5
+ import { clearMiddlewareCache } from '../middleware-runner.js';
6
+ import { clearPermissionsCache } from '../permissions.js';
7
+ import { clearChannelMiddlewareCache } from '../wirings/channel/channel-middleware-runner.js';
8
+ import { httpRouter } from '../wirings/http/routers/http-router.js';
9
+ const readJson = async (logger, file) => {
10
+ let raw;
11
+ try {
12
+ raw = await readFile(file, 'utf-8');
13
+ }
14
+ catch {
15
+ // The project doesn't use this wiring type — nothing generated.
16
+ return undefined;
17
+ }
18
+ try {
19
+ return JSON.parse(raw);
20
+ }
21
+ catch (err) {
22
+ logger.error(`Hot-reload could not parse ${file}: ${err instanceof Error ? err.message : String(err)}`);
23
+ return undefined;
24
+ }
25
+ };
26
+ /**
27
+ * Re-reads the codegen output (wiring meta + JSON schemas) into the running
28
+ * process so new and changed functions become callable without a server
29
+ * restart. The generated `*-meta.gen.ts` files are plain
30
+ * `pikkuState(area, key, <json>)` side effects, but they cannot be
31
+ * re-imported after a dev-time codegen — the ESM cache pins both the wrapper
32
+ * and its JSON import — so this reads the JSON sources directly and applies
33
+ * the same state.
34
+ *
35
+ * Meant to be called by a dev-server watcher after each codegen pass. Routes
36
+ * registered by NEW `wireHTTP` files are not picked up (their modules were
37
+ * never imported); those still need a restart.
38
+ */
39
+ export async function reloadGeneratedMeta(options) {
40
+ const { pikkuDir, logger, schemaService } = options;
41
+ const dir = resolve(pikkuDir);
42
+ const functionsMeta = await readJson(logger, join(dir, 'function/pikku-functions-meta.gen.json'));
43
+ // Merge over the existing map, don't replace it: framework internals like
44
+ // pikkuWorkflowOrchestrator / the per-workflow queue workers are registered
45
+ // at service-init (pikku-workflow-service.ts), never in the generated JSON —
46
+ // a wholesale replace drops them and workflow jobs then fail with
47
+ // "Function meta not found: pikkuWorkflowOrchestrator".
48
+ if (functionsMeta) {
49
+ const existing = pikkuState(null, 'function', 'meta') ?? {};
50
+ pikkuState(null, 'function', 'meta', { ...existing, ...functionsMeta });
51
+ }
52
+ const httpMeta = await readJson(logger, join(dir, 'http/pikku-http-wirings-meta.gen.json'));
53
+ if (httpMeta)
54
+ pikkuState(null, 'http', 'meta', httpMeta);
55
+ const rpcMeta = await readJson(logger, join(dir, 'rpc/pikku-rpc-wirings-meta.internal.gen.json'));
56
+ if (rpcMeta)
57
+ pikkuState(null, 'rpc', 'meta', rpcMeta);
58
+ const queueMeta = await readJson(logger, join(dir, 'queue/pikku-queue-workers-wirings-meta.gen.json'));
59
+ // Same reason as function meta: the workflow service adds its orchestrator /
60
+ // step queues (and wf-orchestrator-* / wf-step-* per-workflow queues) here at
61
+ // init, absent from the generated JSON — merge so they survive the reload.
62
+ if (queueMeta) {
63
+ const existing = pikkuState(null, 'queue', 'meta') ?? {};
64
+ pikkuState(null, 'queue', 'meta', { ...existing, ...queueMeta });
65
+ }
66
+ const agentMeta = await readJson(logger, join(dir, 'agent/pikku-agent-wirings-meta.gen.json'));
67
+ if (agentMeta?.agentsMeta) {
68
+ pikkuState(null, 'agent', 'agentsMeta', agentMeta.agentsMeta);
69
+ }
70
+ // Generated JSON schemas: <outDir>/schemas/schemas/<Name>.schema.json.
71
+ // Re-adding replaces the map entry; the schema service recompiles any
72
+ // validator whose stored schema value no longer matches.
73
+ const schemasDir = join(dir, 'schemas', 'schemas');
74
+ let schemaFiles = [];
75
+ try {
76
+ schemaFiles = await readdir(schemasDir);
77
+ }
78
+ catch {
79
+ // No generated schemas — a schema-less project.
80
+ }
81
+ for (const file of schemaFiles) {
82
+ if (!file.endsWith('.schema.json'))
83
+ continue;
84
+ const schema = await readJson(logger, join(schemasDir, file));
85
+ if (schema) {
86
+ addSchema(file.slice(0, -'.schema.json'.length), schema);
87
+ }
88
+ }
89
+ clearMiddlewareCache();
90
+ clearPermissionsCache();
91
+ clearChannelMiddlewareCache();
92
+ httpRouter.reset();
93
+ try {
94
+ compileAllSchemas(logger, schemaService);
95
+ }
96
+ catch (err) {
97
+ logger.error(`Hot-reload schema recompilation failed: ${err instanceof Error ? err.message : String(err)}`);
98
+ }
99
+ }
@@ -1,5 +1,5 @@
1
1
  import { pikkuState, getSingletonServices } from '../pikku-state.js';
2
- import { createListenerMessageHandler } from '../wirings/gateway/gateway-runner.js';
2
+ import { createListenerMessageHandler, resolveGatewayAdapter, } from '../wirings/gateway/gateway-runner.js';
3
3
  /**
4
4
  * Local GatewayService implementation.
5
5
  *
@@ -27,8 +27,9 @@ export class LocalGatewayService {
27
27
  if (this.activeAdapters.has(name))
28
28
  continue;
29
29
  const handleMessage = createListenerMessageHandler(name, config, singletonServices);
30
- await config.adapter.init(handleMessage);
31
- this.activeAdapters.set(name, config.adapter);
30
+ const adapter = await resolveGatewayAdapter(config, singletonServices);
31
+ await adapter.init(handleMessage);
32
+ this.activeAdapters.set(name, adapter);
32
33
  singletonServices.logger.info(`Started listener gateway: ${name}`);
33
34
  }
34
35
  }
@@ -1,4 +1,5 @@
1
- import type { CoreGateway } from './gateway.types.js';
1
+ import type { CoreGateway, GatewayAdapter } from './gateway.types.js';
2
+ export declare const resolveGatewayAdapter: (config: CoreGateway, services: CoreSingletonServices) => Promise<GatewayAdapter>;
2
3
  import type { CoreSingletonServices } from '../../types/core.types.js';
3
4
  /**
4
5
  * Register a messaging gateway.
@@ -2,6 +2,23 @@ import { pikkuState } from '../../pikku-state.js';
2
2
  import { addFunction } from '../../function/function-runner.js';
3
3
  import { runMiddleware } from '../../middleware-runner.js';
4
4
  import { httpRouter } from '../http/routers/http-router.js';
5
+ /**
6
+ * Lazily resolve a gateway's adapter. Factories are invoked once with the
7
+ * singleton services and cached (promise-cached, so concurrent first
8
+ * requests share one construction).
9
+ */
10
+ const resolvedAdapters = new WeakMap();
11
+ export const resolveGatewayAdapter = (config, services) => {
12
+ let resolved = resolvedAdapters.get(config);
13
+ if (!resolved) {
14
+ resolved =
15
+ typeof config.adapter === 'function'
16
+ ? Promise.resolve(config.adapter(services))
17
+ : Promise.resolve(config.adapter);
18
+ resolvedAdapters.set(config, resolved);
19
+ }
20
+ return resolved;
21
+ };
5
22
  /**
6
23
  * Register a messaging gateway.
7
24
  *
@@ -67,7 +84,9 @@ const wireWebhookGateway = (config) => {
67
84
  auth: false,
68
85
  });
69
86
  // --- GET handler (webhook verification, e.g. WhatsApp challenge) ---------
70
- if (adapter.verifyWebhook) {
87
+ // Factory adapters can't be probed for verifyWebhook until first resolve,
88
+ // so register the GET route unconditionally for them.
89
+ if (typeof adapter === 'function' || adapter.verifyWebhook) {
71
90
  const verifyFuncId = `gateway__${name}__verify`;
72
91
  funcMeta[verifyFuncId] = {
73
92
  pikkuFuncId: verifyFuncId,
@@ -82,7 +101,7 @@ const wireWebhookGateway = (config) => {
82
101
  };
83
102
  const verifyHandler = {
84
103
  auth: false,
85
- func: createWebhookVerifyHandler(adapter),
104
+ func: createWebhookVerifyHandler(config),
86
105
  };
87
106
  addFunction(verifyFuncId, verifyHandler);
88
107
  if (!routes.has('get')) {
@@ -110,9 +129,10 @@ const wireWebhookGateway = (config) => {
110
129
  * 6. Auto-send response via adapter if func returns outbound content
111
130
  */
112
131
  const createWebhookPostHandler = (config) => {
113
- const { name, adapter, func: userFunc, middleware: userMiddleware } = config;
132
+ const { name, func: userFunc, middleware: userMiddleware } = config;
114
133
  const userFuncConfig = userFunc;
115
134
  return async (services, data, wire) => {
135
+ const adapter = await resolveGatewayAdapter(config, services);
116
136
  // Check for POST-based webhook verification (e.g. Slack url_verification)
117
137
  if (adapter.verifyWebhook) {
118
138
  const verifyResult = await adapter.verifyWebhook(data, wire.http?.request);
@@ -157,8 +177,9 @@ const createWebhookPostHandler = (config) => {
157
177
  * Creates the GET handler for webhook verification challenges.
158
178
  * Passes query parameters to the adapter's verifyWebhook method.
159
179
  */
160
- const createWebhookVerifyHandler = (adapter) => {
161
- return async (_services, _data, wire) => {
180
+ const createWebhookVerifyHandler = (config) => {
181
+ return async (services, _data, wire) => {
182
+ const adapter = await resolveGatewayAdapter(config, services);
162
183
  if (!adapter.verifyWebhook) {
163
184
  return { error: 'Verification not supported' };
164
185
  }
@@ -174,7 +195,7 @@ const createWebhookVerifyHandler = (adapter) => {
174
195
  // WebSocket gateway — client connects via WebSocket
175
196
  // ---------------------------------------------------------------------------
176
197
  const wireWebsocketGateway = (config) => {
177
- const { name, route, adapter } = config;
198
+ const { name, route } = config;
178
199
  if (!route) {
179
200
  throw new Error(`WebSocket gateway '${name}' requires a route`);
180
201
  }
@@ -211,8 +232,8 @@ const wireWebsocketGateway = (config) => {
211
232
  // Register onConnect
212
233
  addFunction(connectFuncId, {
213
234
  auth: false,
214
- func: async (_services, _data, wire) => {
215
- ;
235
+ func: async (services, _data, wire) => {
236
+ const adapter = await resolveGatewayAdapter(config, services);
216
237
  wire.gateway = {
217
238
  gatewayName: name,
218
239
  senderId: '',
@@ -227,6 +248,7 @@ const wireWebsocketGateway = (config) => {
227
248
  addFunction(messageFuncId, {
228
249
  auth: false,
229
250
  func: async (services, data, wire) => {
251
+ const adapter = await resolveGatewayAdapter(config, services);
230
252
  const parsed = adapter.parse(data);
231
253
  if (!parsed)
232
254
  return;
@@ -290,10 +312,10 @@ const wireListenerGateway = (config) => {
290
312
  * @param singletonServices - Singleton services to pass to handler/middleware
291
313
  */
292
314
  export const createListenerMessageHandler = (name, config, singletonServices) => {
293
- const { adapter } = config;
294
315
  const userFuncConfig = config.func;
295
316
  const userMiddleware = config.middleware;
296
317
  return async (rawData) => {
318
+ const adapter = await resolveGatewayAdapter(config, singletonServices);
297
319
  const parsed = adapter.parse(rawData);
298
320
  if (!parsed)
299
321
  return;
@@ -1,4 +1,4 @@
1
- import type { CommonWireMeta, CorePikkuMiddleware, CorePikkuMiddlewareGroup } from '../../types/core.types.js';
1
+ import type { CommonWireMeta, CorePikkuMiddleware, CorePikkuMiddlewareGroup, CoreSingletonServices } from '../../types/core.types.js';
2
2
  import type { CorePikkuFunctionConfig, CorePermissionGroup, CorePikkuPermission } from '../../function/functions.types.js';
3
3
  import type { PikkuHTTPRequest } from '../http/http.types.js';
4
4
  /**
@@ -69,6 +69,16 @@ export interface GatewayAdapter {
69
69
  * Receives the data (body or query params) and the Pikku HTTP request for additional inspection. */
70
70
  verifyWebhook?(data: unknown, request?: PikkuHTTPRequest): WebhookVerificationResult | Promise<WebhookVerificationResult>;
71
71
  }
72
+ /**
73
+ * Factory that builds a GatewayAdapter from singleton services.
74
+ *
75
+ * Real platform adapters (WhatsApp Cloud API, Slack, …) need secrets or
76
+ * services that only exist after boot, while `wireGateway` runs at module
77
+ * load. Pass a factory instead of an instance and it is resolved lazily on
78
+ * the first inbound request (webhook/websocket) or on gateway start
79
+ * (listener), then cached for the lifetime of the gateway.
80
+ */
81
+ export type GatewayAdapterFactory = (services: CoreSingletonServices) => GatewayAdapter | Promise<GatewayAdapter>;
72
82
  /**
73
83
  * The gateway wire object available on wire.gateway inside handler functions and middleware
74
84
  */
@@ -100,8 +110,9 @@ export type CoreGateway<PikkuFunctionConfig = CorePikkuFunctionConfig<any, any>,
100
110
  /** HTTP route for webhook/websocket types */
101
111
  route?: string;
102
112
  platform?: string;
103
- /** The gateway adapter (parse inbound, send outbound) */
104
- adapter: GatewayAdapter;
113
+ /** The gateway adapter (parse inbound, send outbound), or a factory
114
+ * resolved lazily from singleton services (for adapters needing secrets) */
115
+ adapter: GatewayAdapter | GatewayAdapterFactory;
105
116
  /** The handler function that processes parsed messages */
106
117
  func: PikkuFunctionConfig;
107
118
  /** Optional middleware chain (e.g., auth) */
@@ -1,2 +1,2 @@
1
- export { wireGateway, createListenerMessageHandler } from './gateway-runner.js';
2
- export type { GatewayAdapter, GatewayAttachment, GatewayInboundMessage, GatewayOutboundMessage, GatewayMeta, GatewaysMeta, GatewayTransportType, CoreGateway, PikkuGateway, WebhookVerificationResult, } from './gateway.types.js';
1
+ export { wireGateway, createListenerMessageHandler, resolveGatewayAdapter, } from './gateway-runner.js';
2
+ export type { GatewayAdapter, GatewayAdapterFactory, GatewayAttachment, GatewayInboundMessage, GatewayOutboundMessage, GatewayMeta, GatewaysMeta, GatewayTransportType, CoreGateway, PikkuGateway, WebhookVerificationResult, } from './gateway.types.js';
@@ -1 +1 @@
1
- export { wireGateway, createListenerMessageHandler } from './gateway-runner.js';
1
+ export { wireGateway, createListenerMessageHandler, resolveGatewayAdapter, } from './gateway-runner.js';
@@ -441,6 +441,9 @@ export async function executeGraphStep(workflowService, rpcService, runId, stepI
441
441
  try {
442
442
  let result;
443
443
  const subWorkflowMeta = pikkuState(null, 'workflows', 'meta')[rpcName];
444
+ const agentMeta = subWorkflowMeta
445
+ ? undefined
446
+ : pikkuState(null, 'agent', 'agentsMeta')[rpcName];
444
447
  if (subWorkflowMeta) {
445
448
  const childWire = {
446
449
  type: 'workflow',
@@ -465,6 +468,10 @@ export async function executeGraphStep(workflowService, rpcService, runId, stepI
465
468
  throw new ChildWorkflowStartedException(runId, stepId, childRunId);
466
469
  }
467
470
  }
471
+ else if (agentMeta) {
472
+ const agentRun = await rpcService.agent.run(rpcName, data);
473
+ result = agentRun.result;
474
+ }
468
475
  else {
469
476
  result = await invokeGraphNodeRpc(workflowService, rpcService, runId, stepId, nodeId, rpcName, data, graphName);
470
477
  }
@@ -523,6 +530,9 @@ async function executeGraphNodeInline(workflowService, rpcService, runId, graphN
523
530
  try {
524
531
  let result;
525
532
  const subWorkflowMeta = pikkuState(null, 'workflows', 'meta')[rpcName];
533
+ const agentMeta = subWorkflowMeta
534
+ ? undefined
535
+ : pikkuState(null, 'agent', 'agentsMeta')[rpcName];
526
536
  if (subWorkflowMeta) {
527
537
  const childWire = {
528
538
  type: 'workflow',
@@ -541,6 +551,10 @@ async function executeGraphNodeInline(workflowService, rpcService, runId, graphN
541
551
  }
542
552
  result = childRun?.output;
543
553
  }
554
+ else if (agentMeta) {
555
+ const agentRun = await rpcService.agent.run(rpcName, input);
556
+ result = agentRun.result;
557
+ }
544
558
  else {
545
559
  result = await invokeGraphNodeRpc(workflowService, rpcService, runId, stepState.stepId, nodeId, rpcName, input, graphName);
546
560
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikku/core",
3
- "version": "0.12.56",
3
+ "version": "0.12.58",
4
4
  "author": "yasser.fadl@gmail.com",
5
5
  "license": "MIT",
6
6
  "module": "dist/index.js",
@@ -72,6 +72,6 @@
72
72
  "typescript": "^6.0.3"
73
73
  },
74
74
  "engines": {
75
- "node": ">=22"
75
+ "node": ">=24"
76
76
  }
77
77
  }
@@ -152,29 +152,36 @@ describe('pikkuDevReloader', { concurrency: false }, () => {
152
152
  assert.ok(reloadLog, 'Should log hot-reload message')
153
153
  })
154
154
 
155
- test('should not replace a function that is not registered', async (t) => {
155
+ test('should register a brand-new function export', async (t) => {
156
156
  if (!(await ensureRecursiveWatchAvailable(t, tmpDir))) return
157
157
 
158
158
  addFunction('registeredFunc', {
159
159
  func: async () => ({ name: 'registered' }),
160
160
  })
161
161
 
162
- await writeFunctionModule(tmpDir, 'unknownFunc.ts', '{ name: "unknown" }')
163
-
164
162
  reloader = await pikkuDevReloader({
165
163
  srcDirectories: [tmpDir],
166
164
  logger: mockLogger,
167
165
  pikkuDir: tmpDir,
168
166
  })
169
167
 
170
- await writeFunctionModule(tmpDir, 'unknownFunc.ts', '{ name: "updated" }')
168
+ await writeFunctionModule(tmpDir, 'unknownFunc.ts', '{ name: "unknown" }')
171
169
 
172
170
  await wait(300)
173
171
 
174
- assert.equal(
175
- pikkuState(null, 'function', 'functions').has('unknownFunc'),
176
- false
177
- )
172
+ const func = pikkuState(null, 'function', 'functions').get('unknownFunc')!
173
+ assert.ok(func, 'New function export should be registered')
174
+ assert.deepEqual(await func.func({} as any, {}, {} as any), {
175
+ name: 'unknown',
176
+ })
177
+ const newLog = mockLogger
178
+ .getLogs()
179
+ .find(
180
+ (l) =>
181
+ l.message.includes('Hot-reloaded') &&
182
+ l.message.includes('new: unknownFunc')
183
+ )
184
+ assert.ok(newLog, 'Should log the newly registered function')
178
185
  })
179
186
 
180
187
  test('should keep old code when JS import fails', async (t) => {