@pikku/core 0.12.20 → 0.12.21

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 (138) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/dist/dev/hot-reload.js +20 -6
  3. package/dist/errors/error-handler.d.ts +1 -1
  4. package/dist/errors/error-handler.js +2 -2
  5. package/dist/function/functions.types.d.ts +3 -2
  6. package/dist/index.d.ts +2 -2
  7. package/dist/index.js +2 -2
  8. package/dist/middleware/index.d.ts +2 -2
  9. package/dist/middleware/index.js +2 -2
  10. package/dist/middleware/telemetry.d.ts +2 -2
  11. package/dist/middleware/telemetry.js +2 -2
  12. package/dist/middleware-runner.d.ts +15 -27
  13. package/dist/middleware-runner.js +25 -30
  14. package/dist/permissions.d.ts +15 -22
  15. package/dist/permissions.js +30 -25
  16. package/dist/pikku-request.js +1 -1
  17. package/dist/pikku-state.js +2 -3
  18. package/dist/services/ai-agent-runner-service.d.ts +1 -0
  19. package/dist/services/in-memory-queue-service.js +1 -1
  20. package/dist/services/in-memory-workflow-service.d.ts +15 -13
  21. package/dist/services/in-memory-workflow-service.js +31 -13
  22. package/dist/services/local-content.js +8 -1
  23. package/dist/services/user-session-service.d.ts +1 -1
  24. package/dist/testing/service-tests.js +24 -0
  25. package/dist/types/core.types.d.ts +4 -0
  26. package/dist/types/state.types.d.ts +2 -18
  27. package/dist/wirings/ai-agent/ai-agent-memory.d.ts +24 -5
  28. package/dist/wirings/ai-agent/ai-agent-memory.js +128 -23
  29. package/dist/wirings/ai-agent/ai-agent-model-config.d.ts +10 -1
  30. package/dist/wirings/ai-agent/ai-agent-model-config.js +15 -35
  31. package/dist/wirings/ai-agent/ai-agent-prepare.js +4 -1
  32. package/dist/wirings/ai-agent/ai-agent-runner.js +45 -31
  33. package/dist/wirings/ai-agent/ai-agent-stream.js +63 -34
  34. package/dist/wirings/ai-agent/ai-agent.types.d.ts +20 -0
  35. package/dist/wirings/channel/channel-handler.js +1 -1
  36. package/dist/wirings/channel/channel-store.d.ts +13 -0
  37. package/dist/wirings/channel/channel.types.d.ts +3 -0
  38. package/dist/wirings/channel/local/local-channel-runner.js +23 -5
  39. package/dist/wirings/channel/pikku-abstract-channel-handler.js +8 -0
  40. package/dist/wirings/channel/serverless/serverless-channel-runner.js +9 -0
  41. package/dist/wirings/cli/cli-runner.js +24 -5
  42. package/dist/wirings/cli/command-parser.js +19 -4
  43. package/dist/wirings/http/http-runner.js +72 -36
  44. package/dist/wirings/http/http.types.d.ts +1 -0
  45. package/dist/wirings/http/pikku-fetch-http-request.js +3 -1
  46. package/dist/wirings/http/web-request.js +32 -0
  47. package/dist/wirings/rpc/rpc-runner.js +13 -3
  48. package/dist/wirings/workflow/graph/graph-node.d.ts +8 -0
  49. package/dist/wirings/workflow/graph/graph-node.js +4 -0
  50. package/dist/wirings/workflow/graph/graph-runner.js +12 -10
  51. package/dist/wirings/workflow/graph/workflow-graph.types.d.ts +4 -0
  52. package/dist/wirings/workflow/index.d.ts +1 -1
  53. package/dist/wirings/workflow/pikku-workflow-service.d.ts +81 -16
  54. package/dist/wirings/workflow/pikku-workflow-service.js +266 -45
  55. package/dist/wirings/workflow/workflow.types.d.ts +34 -0
  56. package/package.json +1 -1
  57. package/run-tests.sh +4 -1
  58. package/src/dev/hot-reload.test.ts +62 -11
  59. package/src/dev/hot-reload.ts +28 -7
  60. package/src/errors/error-handler.ts +8 -2
  61. package/src/errors/error.test.ts +2 -0
  62. package/src/function/function-runner.test.ts +500 -10
  63. package/src/function/functions.types.ts +3 -2
  64. package/src/index.ts +12 -2
  65. package/src/middleware/index.ts +11 -2
  66. package/src/middleware/telemetry.ts +2 -2
  67. package/src/middleware-runner.test.ts +16 -16
  68. package/src/middleware-runner.ts +42 -30
  69. package/src/permissions.test.ts +27 -24
  70. package/src/permissions.ts +41 -25
  71. package/src/pikku-request.test.ts +35 -0
  72. package/src/pikku-request.ts +1 -1
  73. package/src/pikku-state.ts +2 -3
  74. package/src/run-tests-script.test.ts +18 -0
  75. package/src/services/ai-agent-runner-service.ts +1 -0
  76. package/src/services/in-memory-queue-service.ts +1 -1
  77. package/src/services/in-memory-session-store.ts +1 -2
  78. package/src/services/in-memory-workflow-service.test.ts +33 -11
  79. package/src/services/in-memory-workflow-service.ts +49 -13
  80. package/src/services/local-content.test.ts +54 -0
  81. package/src/services/local-content.ts +12 -1
  82. package/src/services/typed-credential-service.ts +3 -3
  83. package/src/services/typed-secret-service.ts +3 -3
  84. package/src/services/typed-variables-service.ts +3 -3
  85. package/src/services/user-session-service.ts +4 -4
  86. package/src/testing/service-tests.ts +30 -0
  87. package/src/types/core.types.ts +6 -2
  88. package/src/types/state.types.ts +2 -13
  89. package/src/wirings/ai-agent/ai-agent-memory.test.ts +324 -0
  90. package/src/wirings/ai-agent/ai-agent-memory.ts +187 -36
  91. package/src/wirings/ai-agent/ai-agent-model-config.test.ts +12 -90
  92. package/src/wirings/ai-agent/ai-agent-model-config.ts +14 -38
  93. package/src/wirings/ai-agent/ai-agent-prepare.test.ts +292 -0
  94. package/src/wirings/ai-agent/ai-agent-prepare.ts +4 -1
  95. package/src/wirings/ai-agent/ai-agent-registry.test.ts +230 -3
  96. package/src/wirings/ai-agent/ai-agent-runner.test.ts +625 -6
  97. package/src/wirings/ai-agent/ai-agent-runner.ts +65 -50
  98. package/src/wirings/ai-agent/ai-agent-stream.test.ts +544 -5
  99. package/src/wirings/ai-agent/ai-agent-stream.ts +71 -69
  100. package/src/wirings/ai-agent/ai-agent.types.ts +24 -0
  101. package/src/wirings/channel/channel-handler.test.ts +272 -0
  102. package/src/wirings/channel/channel-handler.ts +1 -1
  103. package/src/wirings/channel/channel-middleware-runner.test.ts +163 -0
  104. package/src/wirings/channel/channel-store.ts +19 -0
  105. package/src/wirings/channel/channel.types.ts +4 -0
  106. package/src/wirings/channel/local/local-channel-runner.test.ts +63 -0
  107. package/src/wirings/channel/local/local-channel-runner.ts +41 -5
  108. package/src/wirings/channel/local/local-eventhub-service.ts +3 -3
  109. package/src/wirings/channel/pikku-abstract-channel-handler.ts +9 -2
  110. package/src/wirings/channel/serverless/serverless-channel-runner.ts +23 -5
  111. package/src/wirings/cli/channel/cli-raw-channel-runner.ts +7 -2
  112. package/src/wirings/cli/cli-runner.test.ts +255 -2
  113. package/src/wirings/cli/cli-runner.ts +31 -10
  114. package/src/wirings/cli/cli.types.ts +4 -8
  115. package/src/wirings/cli/command-parser.test.ts +83 -0
  116. package/src/wirings/cli/command-parser.ts +23 -4
  117. package/src/wirings/http/http-runner.test.ts +296 -1
  118. package/src/wirings/http/http-runner.ts +87 -57
  119. package/src/wirings/http/http.types.ts +11 -26
  120. package/src/wirings/http/pikku-fetch-http-request.ts +8 -4
  121. package/src/wirings/http/pikku-fetch-http-response.test.ts +41 -0
  122. package/src/wirings/http/web-request.test.ts +115 -0
  123. package/src/wirings/http/web-request.ts +43 -5
  124. package/src/wirings/mcp/mcp-runner.test.ts +367 -0
  125. package/src/wirings/queue/queue.types.ts +2 -5
  126. package/src/wirings/rpc/rpc-runner.test.ts +511 -0
  127. package/src/wirings/rpc/rpc-runner.ts +15 -3
  128. package/src/wirings/rpc/wire-addon.test.ts +57 -0
  129. package/src/wirings/workflow/graph/graph-node.ts +12 -0
  130. package/src/wirings/workflow/graph/graph-runner.test.ts +98 -0
  131. package/src/wirings/workflow/graph/graph-runner.ts +28 -10
  132. package/src/wirings/workflow/graph/workflow-graph.types.ts +4 -0
  133. package/src/wirings/workflow/index.ts +1 -0
  134. package/src/wirings/workflow/pikku-workflow-service.test.ts +19 -2
  135. package/src/wirings/workflow/pikku-workflow-service.ts +370 -71
  136. package/src/wirings/workflow/workflow-queue-workers.test.ts +131 -0
  137. package/src/wirings/workflow/workflow.types.ts +68 -5
  138. package/tsconfig.tsbuildinfo +1 -1
@@ -92,6 +92,17 @@ addError(WorkflowRunNotFoundError, {
92
92
  status: 404,
93
93
  message: 'Workflow run not found.',
94
94
  });
95
+ export class WorkflowRunFailedError extends PikkuError {
96
+ payload;
97
+ constructor(message) {
98
+ super(`Workflow run failed: ${message ?? 'unknown'}`);
99
+ this.payload = { message };
100
+ }
101
+ }
102
+ addError(WorkflowRunFailedError, {
103
+ status: 422,
104
+ message: 'Workflow run failed.',
105
+ });
95
106
  export class WorkflowServiceNotInitialized extends Error {
96
107
  }
97
108
  export class WorkflowStepNameNotString extends Error {
@@ -115,7 +126,38 @@ export class PikkuWorkflowService {
115
126
  get logger() {
116
127
  return getSingletonServices()?.logger;
117
128
  }
118
- constructor() {
129
+ mirror;
130
+ constructor(options = {}) {
131
+ const wireQueues = options.wireQueues ?? true;
132
+ this.mirror = options.mirror;
133
+ if (wireQueues) {
134
+ this.wireQueueWorkers();
135
+ }
136
+ }
137
+ async safeMirror(fn) {
138
+ if (!this.mirror)
139
+ return;
140
+ try {
141
+ await fn();
142
+ }
143
+ catch (err) {
144
+ try {
145
+ this.logger?.warn?.(`[pikku] WorkflowRunMirror write failed: ${err?.message ?? err}`);
146
+ }
147
+ catch {
148
+ // logger unavailable (e.g. singleton services not initialized) — swallow
149
+ }
150
+ }
151
+ }
152
+ rewireQueueWorkers() {
153
+ this.wireQueueWorkers();
154
+ }
155
+ /**
156
+ * Wire the queue-based orchestrator/step/sleeper workers.
157
+ * Subclasses that orchestrate without queues (e.g. Durable Objects) should
158
+ * pass `wireQueues: false` to the base constructor and skip this entirely.
159
+ */
160
+ wireQueueWorkers() {
119
161
  const functions = pikkuState(null, 'function', 'functions');
120
162
  const functionsMeta = pikkuState(null, 'function', 'meta');
121
163
  // Minimal meta for internal workflow functions (satisfies FunctionMeta)
@@ -170,6 +212,9 @@ export class PikkuWorkflowService {
170
212
  func: pikkuWorkflowSleeperFunc,
171
213
  });
172
214
  }
215
+ if (!functionsMeta.pikkuWorkflowSleeper) {
216
+ functionsMeta.pikkuWorkflowSleeper = mkMeta('pikkuWorkflowSleeper');
217
+ }
173
218
  }
174
219
  /**
175
220
  * Check if a run is executing inline (without queues)
@@ -197,6 +242,11 @@ export class PikkuWorkflowService {
197
242
  await this.upsertWorkflowVersion(name, meta.graphHash, meta, meta.source);
198
243
  }
199
244
  }
245
+ async createRun(workflowName, input, inline, graphHash, wire, options) {
246
+ const runId = await this.createRunImpl(workflowName, input, inline, graphHash, wire, options);
247
+ await this.safeMirror(() => this.mirror.createRun(runId, workflowName, input, inline, graphHash, wire, options));
248
+ return runId;
249
+ }
200
250
  /**
201
251
  * Get minimal workflow run status with step summaries.
202
252
  * Used by the public API — the console addon provides the full verbose view.
@@ -240,6 +290,126 @@ export class PikkuWorkflowService {
240
290
  : undefined,
241
291
  };
242
292
  }
293
+ /**
294
+ * Update workflow run status
295
+ * @param id - Run ID
296
+ * @param status - New status
297
+ */
298
+ async updateRunStatus(id, status, output, error) {
299
+ await this.updateRunStatusImpl(id, status, output, error);
300
+ await this.safeMirror(() => this.mirror.updateRunStatus(id, status, output, error));
301
+ }
302
+ /**
303
+ * Insert initial step state (called by orchestrator)
304
+ * Creates pending step in both workflow_step and workflow_step_history
305
+ * @param runId - Run ID
306
+ * @param stepName - Step cache key
307
+ * @param rpcName - RPC function name
308
+ * @param data - Step input data
309
+ * @param stepOptions - Step options (retries, retryDelay)
310
+ * @returns Step state with generated stepId
311
+ */
312
+ async insertStepState(runId, stepName, rpcName, data, stepOptions) {
313
+ const step = await this.insertStepStateImpl(runId, stepName, rpcName, data, stepOptions);
314
+ await this.safeMirror(() => this.mirror.insertStepState(runId, { ...step, stepName, rpcName, data }));
315
+ return step;
316
+ }
317
+ /**
318
+ * Mark step as running
319
+ * Updates both workflow_step and workflow_step_history
320
+ * @param stepId - Step ID
321
+ */
322
+ async setStepRunning(stepId) {
323
+ await this.setStepRunningImpl(stepId);
324
+ await this.safeMirror(() => this.mirror.setStepRunning(stepId));
325
+ }
326
+ /**
327
+ * Mark step as scheduled (queued for execution)
328
+ * Updates both workflow_step and workflow_step_history
329
+ * @param stepId - Step ID
330
+ */
331
+ async setStepScheduled(stepId) {
332
+ await this.setStepScheduledImpl(stepId);
333
+ await this.safeMirror(() => this.mirror.setStepScheduled(stepId));
334
+ }
335
+ /**
336
+ * Store step result and mark as succeeded
337
+ * Updates both workflow_step and workflow_step_history
338
+ * @param stepId - Step ID
339
+ * @param result - Step result
340
+ */
341
+ async setStepResult(stepId, result) {
342
+ await this.setStepResultImpl(stepId, result);
343
+ await this.safeMirror(() => this.mirror.setStepResult(stepId, result));
344
+ }
345
+ /**
346
+ * Set the child workflow run ID on a step
347
+ * @param stepId - Step ID
348
+ * @param childRunId - Child workflow run ID
349
+ */
350
+ async setStepChildRunId(stepId, childRunId) {
351
+ await this.setStepChildRunIdImpl(stepId, childRunId);
352
+ await this.safeMirror(() => this.mirror.setStepChildRunId(stepId, childRunId));
353
+ }
354
+ /**
355
+ * Store step error and mark as failed
356
+ * Updates both workflow_step and workflow_step_history
357
+ * @param stepId - Step ID
358
+ * @param error - Error object
359
+ */
360
+ async setStepError(stepId, error) {
361
+ await this.setStepErrorImpl(stepId, error);
362
+ const serialized = {
363
+ message: error.message,
364
+ stack: error.stack,
365
+ code: error.code,
366
+ };
367
+ await this.safeMirror(() => this.mirror.setStepError(stepId, serialized));
368
+ }
369
+ /**
370
+ * Create a new retry attempt for a failed step
371
+ * Inserts new pending step in both workflow_step and workflow_step_history
372
+ * Resets status to 'pending' with new stepId
373
+ * Copies metadata (rpcName, data, retries, retryDelay) from failed attempt
374
+ * @param failedStepId - Failed step ID to copy from
375
+ * @returns New step state for the retry attempt
376
+ */
377
+ async createRetryAttempt(failedStepId, status) {
378
+ const newStep = await this.createRetryAttemptImpl(failedStepId, status);
379
+ const stepName = newStep.stepName ?? '';
380
+ await this.safeMirror(() => this.mirror.createRetryAttempt(failedStepId, {
381
+ ...newStep,
382
+ stepName,
383
+ }));
384
+ return newStep;
385
+ }
386
+ /**
387
+ * Set the branch key for a graph node step
388
+ * @param stepId - Step ID
389
+ * @param branchKey - Branch key selected by graph.branch()
390
+ */
391
+ async setBranchTaken(stepId, branchKey) {
392
+ await this.setBranchTakenImpl(stepId, branchKey);
393
+ await this.safeMirror(() => this.mirror.setBranchTaken(stepId, branchKey));
394
+ }
395
+ /**
396
+ * Update a state variable in the workflow run's state
397
+ * @param runId - Run ID
398
+ * @param name - Variable name
399
+ * @param value - Value to store
400
+ */
401
+ async updateRunState(runId, name, value) {
402
+ await this.updateRunStateImpl(runId, name, value);
403
+ await this.safeMirror(() => this.mirror.updateRunState(runId, name, value));
404
+ }
405
+ async upsertWorkflowVersion(name, graphHash, graph, source, status) {
406
+ await this.upsertWorkflowVersionImpl(name, graphHash, graph, source, status);
407
+ await this.safeMirror(() => this.mirror.upsertWorkflowVersion(name, graphHash, graph, source, status));
408
+ }
409
+ async updateWorkflowVersionStatus(name, graphHash, status) {
410
+ await this.updateWorkflowVersionStatusImpl(name, graphHash, status);
411
+ await this.safeMirror(() => this.mirror.updateWorkflowVersionStatus(name, graphHash, status));
412
+ }
243
413
  // ============================================================================
244
414
  // Workflow Lifecycle Methods
245
415
  // ============================================================================
@@ -257,14 +427,25 @@ export class PikkuWorkflowService {
257
427
  runId,
258
428
  });
259
429
  }
260
- async queueStepWorker(runId, stepName, rpcName, data) {
430
+ async queueStepWorker(runId, stepName, rpcName, data, stepOptions) {
261
431
  const queueService = this.verifyQueueService();
432
+ const retries = stepOptions?.retries ?? 0;
433
+ const retryDelay = stepOptions?.retryDelay;
262
434
  await queueService.add(this.getStepWorkerQueueName(rpcName), JSON.parse(JSON.stringify({
263
435
  runId,
264
436
  stepName,
265
437
  rpcName,
266
438
  data,
267
- })));
439
+ })), retries > 0 || retryDelay
440
+ ? {
441
+ attempts: retries + 1,
442
+ backoff: typeof retryDelay === 'number'
443
+ ? { type: 'fixed', delay: retryDelay }
444
+ : retryDelay === 'exponential'
445
+ ? 'exponential'
446
+ : undefined,
447
+ }
448
+ : undefined);
268
449
  }
269
450
  /**
270
451
  * Execute a workflow sleep step completion
@@ -288,6 +469,58 @@ export class PikkuWorkflowService {
288
469
  }
289
470
  await queueService.add(this.getOrchestratorQueueName(workflowName), { runId }, retryDelay ? { delay: getDurationInMilliseconds(retryDelay) } : undefined);
290
471
  }
472
+ /**
473
+ * Dispatch a workflow step to be executed asynchronously.
474
+ *
475
+ * Default implementation enqueues a step worker job via the queue service.
476
+ * Subclasses with non-queue transports (e.g. Durable Objects) override this
477
+ * to dispatch via their own mechanism (RPC to a step worker, etc.).
478
+ *
479
+ * On return, the workflow is paused via `WorkflowAsyncException` thrown by
480
+ * the caller; the step transport is responsible for calling back into the
481
+ * orchestrator (via `resumeWorkflow` or equivalent) when the step completes.
482
+ *
483
+ * @returns true if dispatch was async (caller should pause), false to fall
484
+ * through to the inline execution path.
485
+ */
486
+ async dispatchStep(runId, stepName, rpcName, data, stepOptions) {
487
+ if (this.isInline(runId) || !getSingletonServices()?.queueService) {
488
+ return false;
489
+ }
490
+ const retries = stepOptions?.retries ?? 0;
491
+ const retryDelay = stepOptions?.retryDelay;
492
+ await getSingletonServices().queueService.add(this.getStepWorkerQueueName(rpcName), JSON.parse(JSON.stringify({
493
+ runId,
494
+ stepName,
495
+ rpcName,
496
+ data,
497
+ })), {
498
+ attempts: retries + 1,
499
+ backoff: typeof retryDelay === 'number'
500
+ ? { type: 'fixed', delay: retryDelay }
501
+ : retryDelay === 'exponential'
502
+ ? 'exponential'
503
+ : undefined,
504
+ });
505
+ return true;
506
+ }
507
+ /**
508
+ * Schedule a workflow sleep wakeup at the given duration.
509
+ *
510
+ * Default implementation uses the scheduler service to enqueue a delayed
511
+ * sleeper RPC. Subclasses with native timer primitives (e.g. Durable Object
512
+ * alarms) override this to schedule directly without going through queues.
513
+ *
514
+ * @returns true if the wakeup was scheduled remotely (caller should pause),
515
+ * false to fall through to inline `setTimeout` behavior.
516
+ */
517
+ async scheduleSleep(runId, stepId, duration) {
518
+ if (this.isInline(runId) || !getSingletonServices()?.schedulerService) {
519
+ return false;
520
+ }
521
+ await getSingletonServices().schedulerService.scheduleRPC(duration, this.getConfig().sleeperRPCName, { runId, stepId });
522
+ return true;
523
+ }
291
524
  /**
292
525
  * Start a new workflow run
293
526
  * Automatically detects workflow type (DSL or graph) from meta and executes accordingly
@@ -341,7 +574,13 @@ export class PikkuWorkflowService {
341
574
  if (error.name !== 'WorkflowAsyncException' &&
342
575
  error.name !== 'WorkflowCancelledException' &&
343
576
  error.name !== 'WorkflowSuspendedException') {
577
+ await this.updateRunStatus(runId, 'failed', undefined, {
578
+ name: error.name,
579
+ message: error.message,
580
+ stack: error.stack,
581
+ });
344
582
  getSingletonServices().logger.error(`Workflow ${name} (run ${runId}) failed:`, error);
583
+ throw error;
345
584
  }
346
585
  }
347
586
  finally {
@@ -363,7 +602,7 @@ export class PikkuWorkflowService {
363
602
  }
364
603
  if (WORKFLOW_END_STATES.has(run.status)) {
365
604
  if (run.status === 'failed') {
366
- throw new Error(run.error?.message || 'Workflow failed');
605
+ throw new WorkflowRunFailedError(run.error?.message);
367
606
  }
368
607
  if (run.status === 'cancelled') {
369
608
  throw new Error('Workflow was cancelled');
@@ -677,31 +916,14 @@ export class PikkuWorkflowService {
677
916
  }
678
917
  // Step is pending - schedule it
679
918
  await this.setStepScheduled(stepState.stepId);
680
- // Enqueue step worker (unless inline mode)
681
- if (!this.isInline(runId) && getSingletonServices().queueService) {
682
- // Map step retry options to queue job options
683
- const retries = stepOptions?.retries ?? 0;
684
- const retryDelay = stepOptions?.retryDelay;
685
- await getSingletonServices().queueService.add(this.getStepWorkerQueueName(rpcName), JSON.parse(JSON.stringify({
686
- runId,
687
- stepName,
688
- rpcName,
689
- data,
690
- })), {
691
- // attempts includes initial attempt, retries doesn't
692
- attempts: retries + 1,
693
- // Map retry delay to backoff
694
- backoff: typeof retryDelay === 'number'
695
- ? { type: 'fixed', delay: retryDelay }
696
- : retryDelay === 'exponential'
697
- ? 'exponential'
698
- : undefined,
699
- });
700
- // Pause workflow - step will callback when done
919
+ // Hand off to subclass-overridable transport. Default behavior enqueues
920
+ // via the queue service; DO-style subclasses RPC to a step worker.
921
+ const dispatched = await this.dispatchStep(runId, stepName, rpcName, data, stepOptions);
922
+ if (dispatched) {
701
923
  throw new WorkflowAsyncException(runId, stepName);
702
924
  }
703
- else {
704
- // Inline or no queue service - execute locally with retry loop
925
+ {
926
+ // Inline (no transport available) - execute locally with retry loop
705
927
  const retries = stepOptions?.retries ?? 0;
706
928
  const retryDelay = stepOptions?.retryDelay;
707
929
  let currentStepState = stepState;
@@ -873,22 +1095,16 @@ export class PikkuWorkflowService {
873
1095
  }
874
1096
  // Step is pending - schedule it
875
1097
  await this.setStepScheduled(stepState.stepId);
876
- // Check if inline mode or no scheduler service
877
- if (!this.isInline(runId) && getSingletonServices().schedulerService) {
878
- // Remote mode - schedule sleep via scheduler service
879
- await getSingletonServices().schedulerService.scheduleRPC(duration, this.getConfig().sleeperRPCName, {
880
- runId,
881
- stepId: stepState.stepId,
882
- });
883
- // Pause workflow - sleep will callback when done
1098
+ // Hand off to subclass-overridable transport. Default behavior schedules
1099
+ // a delayed sleeper RPC via the scheduler service; DO-style subclasses
1100
+ // override to use native timer primitives (e.g. setAlarm).
1101
+ const scheduled = await this.scheduleSleep(runId, stepState.stepId, duration);
1102
+ if (scheduled) {
884
1103
  throw new WorkflowAsyncException(runId, stepName);
885
1104
  }
886
- else {
887
- // Inline mode - use setTimeout with actual duration
888
- await new Promise((resolve) => setTimeout(resolve, getDurationInMilliseconds(duration)));
889
- await this.setStepResult(stepState.stepId, null);
890
- return;
891
- }
1105
+ // Inline mode - use setTimeout with actual duration
1106
+ await new Promise((resolve) => setTimeout(resolve, getDurationInMilliseconds(duration)));
1107
+ await this.setStepResult(stepState.stepId, null);
892
1108
  }
893
1109
  getSuspendStepName(reason) {
894
1110
  if (!reason) {
@@ -975,12 +1191,17 @@ export class PikkuWorkflowService {
975
1191
  * Get the orchestrator queue name for a specific workflow.
976
1192
  * Checks queue meta for a per-workflow queue first (e.g. wf-orchestrator-{name}),
977
1193
  * falls back to the shared orchestrator queue.
1194
+ *
1195
+ * Reads from `queue.meta` (always populated globally) rather than
1196
+ * `queue.registrations` (only populated for queues this unit consumes).
1197
+ * In a per-unit deploy the orchestrator unit doesn't consume per-step
1198
+ * queues — but it produces to them — so registrations would miss them.
978
1199
  */
979
1200
  getOrchestratorQueueName(workflowName) {
980
1201
  if (workflowName) {
981
1202
  const perWorkflow = `wf-orchestrator-${toKebab(workflowName)}`;
982
- const registrations = pikkuState(null, 'queue', 'registrations');
983
- if (registrations.has(perWorkflow)) {
1203
+ const meta = pikkuState(null, 'queue', 'meta');
1204
+ if (meta[perWorkflow]) {
984
1205
  return perWorkflow;
985
1206
  }
986
1207
  }
@@ -989,8 +1210,8 @@ export class PikkuWorkflowService {
989
1210
  getStepWorkerQueueName(rpcName) {
990
1211
  if (rpcName) {
991
1212
  const perStep = `wf-step-${toKebab(rpcName)}`;
992
- const registrations = pikkuState(null, 'queue', 'registrations');
993
- if (registrations.has(perStep)) {
1213
+ const meta = pikkuState(null, 'queue', 'meta');
1214
+ if (meta[perStep]) {
994
1215
  return perStep;
995
1216
  }
996
1217
  }
@@ -143,6 +143,40 @@ export interface WorkflowRunService {
143
143
  }>>;
144
144
  deleteRun(id: string): Promise<boolean>;
145
145
  }
146
+ /**
147
+ * Write-only companion to `WorkflowRunService`. An executor (a
148
+ * `PikkuWorkflowService` subclass) can be given a mirror; every write
149
+ * the executor makes to its own canonical store is then forwarded here,
150
+ * so an external read store (e.g. a DB queried by the console UI) stays
151
+ * in sync with DO/Redis/etc-driven runs.
152
+ *
153
+ * Mirror failures are logged but never fail the workflow — the mirror is
154
+ * an index, not the source of truth.
155
+ */
156
+ export interface WorkflowRunMirror {
157
+ createRun(runId: string, workflowName: string, input: any, inline: boolean, graphHash: string, wire: WorkflowRunWire, options?: {
158
+ deterministic?: boolean;
159
+ plannedSteps?: WorkflowPlannedStep[];
160
+ }): Promise<void>;
161
+ updateRunStatus(id: string, status: WorkflowStatus, output?: any, error?: SerializedError): Promise<void>;
162
+ insertStepState(runId: string, step: StepState & {
163
+ stepName: string;
164
+ rpcName: string | null;
165
+ data: any;
166
+ }): Promise<void>;
167
+ setStepRunning(stepId: string): Promise<void>;
168
+ setStepScheduled(stepId: string): Promise<void>;
169
+ setStepResult(stepId: string, result: any): Promise<void>;
170
+ setStepChildRunId(stepId: string, childRunId: string): Promise<void>;
171
+ setStepError(stepId: string, error: SerializedError): Promise<void>;
172
+ createRetryAttempt(failedStepId: string, newStep: StepState & {
173
+ stepName: string;
174
+ }): Promise<void>;
175
+ setBranchTaken(stepId: string, branchKey: string): Promise<void>;
176
+ updateRunState(runId: string, name: string, value: unknown): Promise<void>;
177
+ upsertWorkflowVersion(name: string, graphHash: string, graph: any, source: string, status?: WorkflowVersionStatus): Promise<void>;
178
+ updateWorkflowVersionStatus(name: string, graphHash: string, status: WorkflowVersionStatus): Promise<void>;
179
+ }
146
180
  /**
147
181
  * Core workflow definition
148
182
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikku/core",
3
- "version": "0.12.20",
3
+ "version": "0.12.21",
4
4
  "author": "yasser.fadl@gmail.com",
5
5
  "license": "MIT",
6
6
  "module": "dist/index.js",
package/run-tests.sh CHANGED
@@ -1,5 +1,8 @@
1
1
  #!/bin/bash
2
2
 
3
+ script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
4
+ cd "$script_dir"
5
+
3
6
  # Enable nullglob to handle cases where no files match the pattern
4
7
  shopt -s nullglob
5
8
 
@@ -34,7 +37,7 @@ files=($(find src -type f -name "*.test.ts"))
34
37
  # Check if any files matched the pattern
35
38
  if [ ${#files[@]} -eq 0 ]; then
36
39
  echo "No test files found matching pattern: $pattern"
37
- if [ "${STRICT_TEST_DISCOVERY}" = "1" ] || [ "${CI}" = "true" ]; then
40
+ if [ "${STRICT_TEST_DISCOVERY}" = "1" ]; then
38
41
  exit 1
39
42
  fi
40
43
  exit 0
@@ -1,5 +1,12 @@
1
- import { describe, test, beforeEach, afterEach } from 'node:test'
1
+ import {
2
+ describe,
3
+ test,
4
+ beforeEach,
5
+ afterEach,
6
+ type TestContext,
7
+ } from 'node:test'
2
8
  import assert from 'node:assert/strict'
9
+ import { watch } from 'node:fs'
3
10
  import { mkdtemp, writeFile, rm, mkdir } from 'node:fs/promises'
4
11
  import { join } from 'node:path'
5
12
  import { tmpdir } from 'node:os'
@@ -21,6 +28,32 @@ import {
21
28
 
22
29
  const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
23
30
 
31
+ const ensureRecursiveWatchAvailable = async (
32
+ t: TestContext,
33
+ dir: string
34
+ ): Promise<boolean> => {
35
+ if (process.platform === 'darwin') {
36
+ t.skip('recursive fs.watch is unreliable on darwin')
37
+ return false
38
+ }
39
+
40
+ try {
41
+ const watcher = watch(dir, { recursive: true }, () => {})
42
+ watcher.close()
43
+ return true
44
+ } catch (error: any) {
45
+ if (
46
+ error?.code === 'EMFILE' ||
47
+ error?.code === 'ERR_FEATURE_UNAVAILABLE_ON_PLATFORM' ||
48
+ error?.code === 'ERR_INVALID_ARG_VALUE'
49
+ ) {
50
+ t.skip(`recursive fs.watch unavailable: ${error.code}`)
51
+ return false
52
+ }
53
+ throw error
54
+ }
55
+ }
56
+
24
57
  const createMockLogger = () => {
25
58
  const logs: Array<{ level: string; message: string }> = []
26
59
  return {
@@ -47,7 +80,7 @@ const writeFunctionModule = async (
47
80
  await writeFile(join(dir, filename), `// ts trigger ${Date.now()}`)
48
81
  }
49
82
 
50
- describe('pikkuDevReloader', () => {
83
+ describe('pikkuDevReloader', { concurrency: false }, () => {
51
84
  let tmpDir: string
52
85
  let reloader: { close: () => void } | undefined
53
86
  let mockLogger: ReturnType<typeof createMockLogger>
@@ -76,7 +109,9 @@ describe('pikkuDevReloader', () => {
76
109
  await rm(tmpDir, { recursive: true, force: true })
77
110
  })
78
111
 
79
- test('should hot-reload a function and pick up new return value', async () => {
112
+ test('should hot-reload a function and pick up new return value', async (t) => {
113
+ if (!(await ensureRecursiveWatchAvailable(t, tmpDir))) return
114
+
80
115
  addFunction('myFunc', {
81
116
  func: async () => ({ version: 1 }),
82
117
  })
@@ -112,7 +147,9 @@ describe('pikkuDevReloader', () => {
112
147
  assert.ok(reloadLog, 'Should log hot-reload message')
113
148
  })
114
149
 
115
- test('should not replace a function that is not registered', async () => {
150
+ test('should not replace a function that is not registered', async (t) => {
151
+ if (!(await ensureRecursiveWatchAvailable(t, tmpDir))) return
152
+
116
153
  addFunction('registeredFunc', {
117
154
  func: async () => ({ name: 'registered' }),
118
155
  })
@@ -135,7 +172,9 @@ describe('pikkuDevReloader', () => {
135
172
  )
136
173
  })
137
174
 
138
- test('should keep old code when JS import fails', async () => {
175
+ test('should keep old code when JS import fails', async (t) => {
176
+ if (!(await ensureRecursiveWatchAvailable(t, tmpDir))) return
177
+
139
178
  addFunction('badFunc', {
140
179
  func: async () => ({ working: true }),
141
180
  })
@@ -166,7 +205,9 @@ describe('pikkuDevReloader', () => {
166
205
  })
167
206
  })
168
207
 
169
- test('should ignore non-ts files, test files, and gen files', async () => {
208
+ test('should ignore non-ts files, test files, and gen files', async (t) => {
209
+ if (!(await ensureRecursiveWatchAvailable(t, tmpDir))) return
210
+
170
211
  addFunction('someFunc', {
171
212
  func: async () => ({ original: true }),
172
213
  })
@@ -190,7 +231,9 @@ describe('pikkuDevReloader', () => {
190
231
  assert.equal(reloadLogs.length, 0)
191
232
  })
192
233
 
193
- test('should hot-reload function used via HTTP wire', async () => {
234
+ test('should hot-reload function used via HTTP wire', async (t) => {
235
+ if (!(await ensureRecursiveWatchAvailable(t, tmpDir))) return
236
+
194
237
  const sessionMiddleware = async (_services: any, wire: any, next: any) => {
195
238
  wire.setSession?.({ userId: 'test' } as any)
196
239
  await next()
@@ -251,7 +294,9 @@ describe('pikkuDevReloader', () => {
251
294
  })
252
295
  })
253
296
 
254
- test('should hot-reload function used via scheduler wire', async () => {
297
+ test('should hot-reload function used via scheduler wire', async (t) => {
298
+ if (!(await ensureRecursiveWatchAvailable(t, tmpDir))) return
299
+
255
300
  const taskResult = { ref: 'initial' }
256
301
 
257
302
  pikkuState(null, 'scheduler', 'meta')['hotTask'] = {
@@ -371,7 +416,9 @@ describe('pikkuDevReloader', () => {
371
416
  assert.deepEqual(resultV2, { result: 'v2' })
372
417
  })
373
418
 
374
- test('should debounce rapid file changes', async () => {
419
+ test('should debounce rapid file changes', async (t) => {
420
+ if (!(await ensureRecursiveWatchAvailable(t, tmpDir))) return
421
+
375
422
  addFunction('debounceFunc', {
376
423
  func: async () => ({ count: 0 }),
377
424
  })
@@ -396,7 +443,9 @@ describe('pikkuDevReloader', () => {
396
443
  assert.equal(result.count, 5)
397
444
  })
398
445
 
399
- test('should watch subdirectories', async () => {
446
+ test('should watch subdirectories', async (t) => {
447
+ if (!(await ensureRecursiveWatchAvailable(t, tmpDir))) return
448
+
400
449
  const subDir = join(tmpDir, 'functions')
401
450
  await mkdir(subDir)
402
451
 
@@ -426,7 +475,9 @@ describe('pikkuDevReloader', () => {
426
475
  })
427
476
  })
428
477
 
429
- test('should properly clean up on close', async () => {
478
+ test('should properly clean up on close', async (t) => {
479
+ if (!(await ensureRecursiveWatchAvailable(t, tmpDir))) return
480
+
430
481
  addFunction('cleanupFunc', {
431
482
  func: async () => ({ v: 1 }),
432
483
  })