@pikku/core 0.12.3 → 0.12.5

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,4 +1,20 @@
1
- ## 0.12.0
1
+ ## 0.12.4
2
+
3
+ ## 0.12.5
4
+
5
+ ### Patch Changes
6
+
7
+ - 198e68f: Add hot-reload for dev mode: reload functions, middleware, and permissions without server restart.
8
+
9
+ ## 0.12.4
10
+
11
+ ### Patch Changes
12
+
13
+ - 688b5e8: InMemoryWorkflowService now implements WorkflowRunService interface, adding listRuns, getRunSteps, getDistinctWorkflowNames, and deleteRun methods.
14
+
15
+ ### Patch Changes
16
+
17
+ - InMemoryWorkflowService now implements WorkflowRunService interface (listRuns, getRunSteps, getDistinctWorkflowNames, deleteRun)
2
18
 
3
19
  ## 0.12.3
4
20
 
@@ -0,0 +1,10 @@
1
+ import type { Logger } from '../services/logger.js';
2
+ interface PikkuDevReloaderOptions {
3
+ srcDirectories: string[];
4
+ logger: Logger;
5
+ pikkuDir?: string;
6
+ }
7
+ export declare function pikkuDevReloader(options: PikkuDevReloaderOptions): Promise<{
8
+ close: () => void;
9
+ }>;
10
+ export {};
@@ -0,0 +1,158 @@
1
+ import { watch } from 'node:fs';
2
+ import { stat, readFile } from 'node:fs/promises';
3
+ import { join, resolve, relative } from 'node:path';
4
+ import { pikkuState } from '../pikku-state.js';
5
+ import { addFunction } from '../function/function-runner.js';
6
+ import { clearMiddlewareCache } from '../middleware-runner.js';
7
+ import { clearPermissionsCache } from '../permissions.js';
8
+ import { clearChannelMiddlewareCache } from '../wirings/channel/channel-middleware-runner.js';
9
+ import { httpRouter } from '../wirings/http/routers/http-router.js';
10
+ import { addSchema, compileAllSchemas } from '../schema.js';
11
+ const isFunctionConfig = (value) => {
12
+ return (typeof value === 'object' &&
13
+ value !== null &&
14
+ 'func' in value &&
15
+ typeof value.func === 'function');
16
+ };
17
+ const findCompiledFile = async (tsFile, srcDir, pikkuDir) => {
18
+ const rel = relative(srcDir, tsFile).replace(/\.ts$/, '.js');
19
+ const candidates = [
20
+ join(pikkuDir, 'dist', rel),
21
+ join(srcDir, rel),
22
+ tsFile.replace(/\.ts$/, '.js'),
23
+ ];
24
+ for (const candidate of candidates) {
25
+ try {
26
+ await stat(candidate);
27
+ return candidate;
28
+ }
29
+ catch {
30
+ // not found, try next
31
+ }
32
+ }
33
+ return null;
34
+ };
35
+ // Use data: URLs to import modules. This bypasses TypeScript loaders
36
+ // (e.g. tsx) that intercept file:// imports and break dynamic ESM loading.
37
+ // Each import gets unique content so there's no module cache to worry about.
38
+ const reimportModule = async (filePath) => {
39
+ try {
40
+ const content = await readFile(resolve(filePath), 'utf-8');
41
+ const dataUrl = 'data:text/javascript;base64,' + Buffer.from(content).toString('base64');
42
+ return await import(dataUrl);
43
+ }
44
+ catch {
45
+ return null;
46
+ }
47
+ };
48
+ const isWatchedTsFile = (filename) => {
49
+ return (filename.endsWith('.ts') &&
50
+ !filename.endsWith('.test.ts') &&
51
+ !filename.endsWith('.d.ts') &&
52
+ !filename.endsWith('.gen.ts'));
53
+ };
54
+ export async function pikkuDevReloader(options) {
55
+ const { srcDirectories, logger, pikkuDir = '.pikku' } = options;
56
+ const absSrcDirs = srcDirectories.map((d) => resolve(d));
57
+ const absPikkuDir = resolve(pikkuDir);
58
+ const watchers = [];
59
+ const functionsMap = pikkuState(null, 'function', 'functions');
60
+ const handleFileChange = async (changedTsFile) => {
61
+ const start = Date.now();
62
+ const reloadedNames = [];
63
+ const srcDir = absSrcDirs.find((d) => changedTsFile.startsWith(d));
64
+ if (!srcDir)
65
+ return;
66
+ const compiledFile = await findCompiledFile(changedTsFile, srcDir, absPikkuDir);
67
+ if (!compiledFile) {
68
+ logger.warn(`Could not find compiled JS for: ${relative(process.cwd(), changedTsFile)}`);
69
+ return;
70
+ }
71
+ const mod = await reimportModule(compiledFile);
72
+ if (!mod) {
73
+ logger.error(`Failed to import: ${relative(process.cwd(), compiledFile)} (keeping old code)`);
74
+ return;
75
+ }
76
+ let schemasChanged = false;
77
+ for (const [exportName, exportValue] of Object.entries(mod)) {
78
+ if (isFunctionConfig(exportValue) && functionsMap.has(exportName)) {
79
+ addFunction(exportName, exportValue);
80
+ reloadedNames.push(exportName);
81
+ if (exportValue.input) {
82
+ addSchema(exportName, exportValue.input);
83
+ schemasChanged = true;
84
+ }
85
+ if (exportValue.output) {
86
+ addSchema(`${exportName}Output`, exportValue.output);
87
+ schemasChanged = true;
88
+ }
89
+ }
90
+ }
91
+ if (reloadedNames.length > 0) {
92
+ clearMiddlewareCache();
93
+ clearPermissionsCache();
94
+ clearChannelMiddlewareCache();
95
+ httpRouter.reset();
96
+ if (schemasChanged) {
97
+ try {
98
+ compileAllSchemas(logger);
99
+ }
100
+ catch (err) {
101
+ const msg = err instanceof Error ? err.message : String(err);
102
+ if (msg.includes('SchemaService') ||
103
+ (msg.includes('schema') && msg.includes('not'))) {
104
+ logger.warn('Schema recompilation skipped (no SchemaService)');
105
+ }
106
+ else {
107
+ logger.error(`Schema recompilation failed: ${msg}`);
108
+ return;
109
+ }
110
+ }
111
+ }
112
+ const elapsed = Date.now() - start;
113
+ logger.info(`Hot-reloaded: ${reloadedNames.join(', ')} (${elapsed}ms)`);
114
+ }
115
+ };
116
+ let debounceTimer;
117
+ const pendingChanges = new Set();
118
+ const scheduleReload = (filePath) => {
119
+ pendingChanges.add(filePath);
120
+ if (debounceTimer)
121
+ clearTimeout(debounceTimer);
122
+ debounceTimer = setTimeout(async () => {
123
+ const files = [...pendingChanges];
124
+ pendingChanges.clear();
125
+ for (const file of files) {
126
+ try {
127
+ await handleFileChange(file);
128
+ }
129
+ catch (err) {
130
+ logger.error(`Hot-reload error for ${relative(process.cwd(), file)}: ${err instanceof Error ? err.message : String(err)}`);
131
+ }
132
+ }
133
+ }, 50);
134
+ };
135
+ for (const srcDir of absSrcDirs) {
136
+ try {
137
+ const watcher = watch(srcDir, { recursive: true }, (eventType, filename) => {
138
+ if (filename && isWatchedTsFile(filename)) {
139
+ scheduleReload(join(srcDir, filename));
140
+ }
141
+ });
142
+ watchers.push(watcher);
143
+ }
144
+ catch (err) {
145
+ logger.error(`Failed to watch directory ${srcDir}: ${err?.message || err}`);
146
+ }
147
+ }
148
+ logger.info(`Hot-reload active for: ${srcDirectories.join(', ')}`);
149
+ return {
150
+ close: () => {
151
+ if (debounceTimer)
152
+ clearTimeout(debounceTimer);
153
+ for (const watcher of watchers) {
154
+ watcher.close();
155
+ }
156
+ },
157
+ };
158
+ }
@@ -51,6 +51,7 @@ export declare const runMiddleware: <Middleware extends CorePikkuMiddleware>(ser
51
51
  * ```
52
52
  */
53
53
  export declare const addMiddleware: <PikkuMiddleware extends CorePikkuMiddleware>(tag: string, middleware: CorePikkuMiddlewareGroup, packageName?: string | null) => CorePikkuMiddlewareGroup;
54
+ export declare const clearMiddlewareCache: () => void;
54
55
  /**
55
56
  * Combines wiring-specific middleware with function-level middleware.
56
57
  *
@@ -98,6 +98,11 @@ const middlewareCache = {
98
98
  workflow: {},
99
99
  gateway: {},
100
100
  };
101
+ export const clearMiddlewareCache = () => {
102
+ for (const key of Object.keys(middlewareCache)) {
103
+ middlewareCache[key] = {};
104
+ }
105
+ };
101
106
  /**
102
107
  * Combines wiring-specific middleware with function-level middleware.
103
108
  *
@@ -29,6 +29,7 @@ import type { CorePermissionGroup, CorePikkuPermission } from './function/functi
29
29
  * ```
30
30
  */
31
31
  export declare const addPermission: (tag: string, permissions: CorePermissionGroup | CorePikkuPermission[], packageName?: string | null) => CorePermissionGroup | CorePikkuPermission[];
32
+ export declare const clearPermissionsCache: () => void;
32
33
  /**
33
34
  * Runs permission checks using combined permissions from all sources.
34
35
  * Combines permissions from wire and function levels, then validates them.
@@ -101,6 +101,11 @@ const combinedPermissionsCache = {
101
101
  workflow: {},
102
102
  gateway: {},
103
103
  };
104
+ export const clearPermissionsCache = () => {
105
+ for (const key of Object.keys(combinedPermissionsCache)) {
106
+ combinedPermissionsCache[key] = {};
107
+ }
108
+ };
104
109
  /**
105
110
  * Combines wiring-specific permissions with function-level permissions.
106
111
  *
@@ -1,6 +1,6 @@
1
1
  import { PikkuWorkflowService } from '../wirings/workflow/pikku-workflow-service.js';
2
2
  import type { SerializedError } from '../types/core.types.js';
3
- import type { WorkflowRun, WorkflowRunWire, StepState, WorkflowStatus, WorkflowVersionStatus, WorkflowStepOptions } from '../wirings/workflow/workflow.types.js';
3
+ import type { WorkflowRun, WorkflowRunService, WorkflowRunWire, StepState, WorkflowStatus, WorkflowVersionStatus, WorkflowStepOptions } from '../wirings/workflow/workflow.types.js';
4
4
  /**
5
5
  * In-memory implementation of WorkflowService for inline-only execution
6
6
  *
@@ -16,7 +16,7 @@ import type { WorkflowRun, WorkflowRunWire, StepState, WorkflowStatus, WorkflowV
16
16
  * await workflowService.startWorkflow('myWorkflow', input, { type: 'cli' }, rpc, { inline: true })
17
17
  * ```
18
18
  */
19
- export declare class InMemoryWorkflowService extends PikkuWorkflowService {
19
+ export declare class InMemoryWorkflowService extends PikkuWorkflowService implements WorkflowRunService {
20
20
  private runs;
21
21
  private steps;
22
22
  private stepData;
@@ -37,6 +37,19 @@ export declare class InMemoryWorkflowService extends PikkuWorkflowService {
37
37
  setStepResult(stepId: string, result: any): Promise<void>;
38
38
  setStepError(stepId: string, error: Error): Promise<void>;
39
39
  createRetryAttempt(failedStepId: string, status: 'pending' | 'running'): Promise<StepState>;
40
+ listRuns(options?: {
41
+ workflowName?: string;
42
+ status?: string;
43
+ limit?: number;
44
+ offset?: number;
45
+ }): Promise<WorkflowRun[]>;
46
+ getRunSteps(runId: string): Promise<Array<StepState & {
47
+ stepName: string;
48
+ rpcName?: string;
49
+ data?: any;
50
+ }>>;
51
+ getDistinctWorkflowNames(): Promise<string[]>;
52
+ deleteRun(id: string): Promise<boolean>;
40
53
  withRunLock<T>(_id: string, fn: () => Promise<T>): Promise<T>;
41
54
  withStepLock<T>(_runId: string, _stepName: string, fn: () => Promise<T>): Promise<T>;
42
55
  close(): Promise<void>;
@@ -73,13 +73,14 @@ export class InMemoryWorkflowService extends PikkuWorkflowService {
73
73
  retryDelay: stepOptions?.retryDelay,
74
74
  createdAt: now,
75
75
  updatedAt: now,
76
+ stepName,
76
77
  };
77
78
  const key = `${runId}:${stepName}`;
78
79
  this.steps.set(key, step);
79
80
  this.stepData.set(stepId, { rpcName, data, stepName });
80
- // Add to history
81
+ // Add to history (same reference so mutations are reflected)
81
82
  const history = this.stepHistory.get(runId) || [];
82
- history.push({ ...step, stepName });
83
+ history.push(step);
83
84
  this.stepHistory.set(runId, history);
84
85
  return step;
85
86
  }
@@ -87,13 +88,7 @@ export class InMemoryWorkflowService extends PikkuWorkflowService {
87
88
  const key = `${runId}:${stepName}`;
88
89
  const step = this.steps.get(key);
89
90
  if (!step) {
90
- return {
91
- stepId: '',
92
- status: 'pending',
93
- attemptCount: 0,
94
- createdAt: new Date(),
95
- updatedAt: new Date(),
96
- };
91
+ throw new Error(`Step not found: runId=${runId}, stepName=${stepName}. Use insertStepState to create it.`);
97
92
  }
98
93
  return step;
99
94
  }
@@ -171,6 +166,7 @@ export class InMemoryWorkflowService extends PikkuWorkflowService {
171
166
  retryDelay: failedStep.retryDelay,
172
167
  createdAt: now,
173
168
  updatedAt: now,
169
+ stepName: stepName,
174
170
  };
175
171
  if (status === 'running') {
176
172
  newStep.runningAt = now;
@@ -181,12 +177,61 @@ export class InMemoryWorkflowService extends PikkuWorkflowService {
181
177
  if (failedStepData) {
182
178
  this.stepData.set(newStepId, { ...failedStepData });
183
179
  }
184
- // Add to history
180
+ // Add to history (same reference so mutations are reflected)
185
181
  const history = this.stepHistory.get(runId) || [];
186
- history.push({ ...newStep, stepName });
182
+ history.push(newStep);
187
183
  this.stepHistory.set(runId, history);
188
184
  return newStep;
189
185
  }
186
+ async listRuns(options) {
187
+ let runs = Array.from(this.runs.values());
188
+ if (options?.workflowName) {
189
+ runs = runs.filter((r) => r.workflow === options.workflowName);
190
+ }
191
+ if (options?.status) {
192
+ runs = runs.filter((r) => r.status === options.status);
193
+ }
194
+ runs.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
195
+ const offset = options?.offset ?? 0;
196
+ const limit = options?.limit ?? runs.length;
197
+ return runs.slice(offset, offset + limit);
198
+ }
199
+ async getRunSteps(runId) {
200
+ const history = this.stepHistory.get(runId) || [];
201
+ return history.map((step) => {
202
+ const stepDataEntry = this.stepData.get(step.stepId);
203
+ return {
204
+ ...step,
205
+ rpcName: stepDataEntry?.rpcName ?? undefined,
206
+ data: stepDataEntry?.data,
207
+ };
208
+ });
209
+ }
210
+ async getDistinctWorkflowNames() {
211
+ const names = new Set();
212
+ for (const run of this.runs.values()) {
213
+ names.add(run.workflow);
214
+ }
215
+ return Array.from(names);
216
+ }
217
+ async deleteRun(id) {
218
+ const existed = this.runs.has(id);
219
+ this.runs.delete(id);
220
+ this.stepHistory.delete(id);
221
+ this.runState.delete(id);
222
+ const prefix = `${id}:`;
223
+ for (const key of this.steps.keys()) {
224
+ if (key.startsWith(prefix)) {
225
+ const step = this.steps.get(key);
226
+ if (step) {
227
+ this.stepData.delete(step.stepId);
228
+ this.branchKeys.delete(step.stepId);
229
+ }
230
+ this.steps.delete(key);
231
+ }
232
+ }
233
+ return existed;
234
+ }
190
235
  async withRunLock(_id, fn) {
191
236
  // In-memory service doesn't need locking for inline execution
192
237
  return fn();
@@ -5,7 +5,7 @@ import { randomUUID } from 'crypto';
5
5
  import { streamAIAgent } from './ai-agent-stream.js';
6
6
  import { runAIAgent } from './ai-agent-runner.js';
7
7
  import { resolveNamespace, ContextAwareRPCService, } from '../../wirings/rpc/rpc-runner.js';
8
- import { buildDynamicWorkflowInstructions, buildWorkflowTools } from './agent-dynamic-workflow.js';
8
+ import { buildDynamicWorkflowInstructions, buildWorkflowTools, } from './agent-dynamic-workflow.js';
9
9
  import { resolveMemoryServices, loadContextMessages, trimMessages, } from './ai-agent-memory.js';
10
10
  import { resolveModelConfig } from './ai-agent-model-config.js';
11
11
  export class ToolApprovalRequired extends PikkuError {
@@ -272,6 +272,21 @@ export async function buildToolDefs(params, agentSessionMap, resourceId, agentNa
272
272
  }
273
273
  // No stream context: sub-agent runs non-streaming
274
274
  const result = await runAIAgent(subAgentName, { message, threadId, resourceId }, params, agentSessionMap);
275
+ if (result.status === 'suspended' &&
276
+ result.pendingApprovals?.length) {
277
+ return {
278
+ __approvalRequired: true,
279
+ toolName: subAgentName,
280
+ args: toolInput,
281
+ agentRunId: result.runId,
282
+ subApprovals: result.pendingApprovals.map((a) => ({
283
+ toolCallId: a.toolCallId,
284
+ toolName: a.toolName,
285
+ args: a.args,
286
+ runId: a.runId,
287
+ })),
288
+ };
289
+ }
275
290
  return result.object ?? result.text;
276
291
  },
277
292
  });
@@ -1,6 +1,7 @@
1
1
  import type { CoreSingletonServices, MiddlewareMetadata, PikkuWire } from '../../types/core.types.js';
2
2
  import type { CorePikkuChannelMiddleware } from './channel.types.js';
3
3
  export declare const addChannelMiddleware: (tag: string, middleware: CorePikkuChannelMiddleware[], packageName?: string | null) => CorePikkuChannelMiddleware[];
4
+ export declare const clearChannelMiddlewareCache: () => void;
4
5
  export declare const combineChannelMiddleware: (wireType: string, uid: string, { wireInheritedChannelMiddleware, wireChannelMiddleware, packageName, }?: {
5
6
  wireInheritedChannelMiddleware?: MiddlewareMetadata[];
6
7
  wireChannelMiddleware?: CorePikkuChannelMiddleware[];
@@ -11,6 +11,11 @@ const getChannelMiddlewareByName = (name) => {
11
11
  return middleware?.[0];
12
12
  };
13
13
  const channelMiddlewareCache = {};
14
+ export const clearChannelMiddlewareCache = () => {
15
+ for (const key of Object.keys(channelMiddlewareCache)) {
16
+ delete channelMiddlewareCache[key];
17
+ }
18
+ };
14
19
  export const combineChannelMiddleware = (wireType, uid, { wireInheritedChannelMiddleware, wireChannelMiddleware, packageName = null, } = {}) => {
15
20
  const cacheKey = `${wireType}:${uid}`;
16
21
  if (channelMiddlewareCache[cacheKey]) {
@@ -175,17 +175,16 @@ export class PikkuWorkflowService {
175
175
  if (!workflowMeta.graphHash) {
176
176
  throw new Error(`Missing workflow graphHash for '${name}'`);
177
177
  }
178
- const runId = await this.createRun(name, input, options?.inline ?? false, workflowMeta.graphHash, wire);
179
- if (options?.inline) {
178
+ const shouldInline = options?.inline || !getSingletonServices()?.queueService;
179
+ const runId = await this.createRun(name, input, shouldInline, workflowMeta.graphHash, wire);
180
+ if (shouldInline) {
180
181
  this.inlineRuns.add(runId);
181
- }
182
- if (options?.inline || !getSingletonServices()?.queueService) {
183
182
  this.runWorkflowJob(runId, rpcService)
184
- .catch(() => { })
183
+ .catch((err) => {
184
+ getSingletonServices().logger.error(`Workflow ${name} (run ${runId}) failed:`, err);
185
+ })
185
186
  .finally(() => {
186
- if (options?.inline) {
187
- this.inlineRuns.delete(runId);
188
- }
187
+ this.inlineRuns.delete(runId);
189
188
  });
190
189
  }
191
190
  else {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikku/core",
3
- "version": "0.12.3",
3
+ "version": "0.12.5",
4
4
  "author": "yasser.fadl@gmail.com",
5
5
  "license": "MIT",
6
6
  "module": "dist/index.js",
@@ -44,7 +44,8 @@
44
44
  "./crypto-utils": "./dist/crypto-utils.js",
45
45
  "./internal": "./dist/internal.js",
46
46
  "./schema": "./dist/schema.js",
47
- "./testing": "./dist/testing/index.js"
47
+ "./testing": "./dist/testing/index.js",
48
+ "./dev": "./dist/dev/hot-reload.js"
48
49
  },
49
50
  "dependencies": {
50
51
  "@standard-schema/spec": "^1.1.0",