@pikku/core 0.12.62 → 0.12.64

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 (198) hide show
  1. package/CHANGELOG.md +153 -0
  2. package/dist/dev/hot-reload.js +13 -55
  3. package/dist/dev/module-runner.d.ts +14 -0
  4. package/dist/dev/module-runner.js +59 -0
  5. package/dist/dev/reload-meta.d.ts +13 -0
  6. package/dist/dev/reload-meta.js +22 -0
  7. package/dist/errors/errors.d.ts +12 -0
  8. package/dist/errors/errors.js +19 -0
  9. package/dist/function/function-runner.d.ts +5 -5
  10. package/dist/function/function-runner.js +39 -111
  11. package/dist/function/functions.types.d.ts +17 -7
  12. package/dist/index.d.ts +5 -1
  13. package/dist/index.js +3 -1
  14. package/dist/middleware/auth-apikey.d.ts +1 -0
  15. package/dist/middleware/auth-bearer.d.ts +1 -0
  16. package/dist/middleware/auth-cookie.d.ts +1 -0
  17. package/dist/middleware/cors.d.ts +1 -0
  18. package/dist/middleware/index.d.ts +1 -1
  19. package/dist/middleware/index.js +1 -1
  20. package/dist/middleware/remote-auth.d.ts +1 -0
  21. package/dist/middleware/telemetry.d.ts +2 -0
  22. package/dist/middleware/timeout.d.ts +1 -0
  23. package/dist/permissions.d.ts +32 -25
  24. package/dist/permissions.js +104 -185
  25. package/dist/pikku-state.js +0 -2
  26. package/dist/scopes.d.ts +17 -0
  27. package/dist/scopes.js +59 -0
  28. package/dist/services/ai-embedding-service.d.ts +31 -0
  29. package/dist/services/ai-embedding-service.js +1 -0
  30. package/dist/services/credential-wire-service.d.ts +3 -1
  31. package/dist/services/credential-wire-service.js +10 -4
  32. package/dist/services/index.d.ts +4 -0
  33. package/dist/services/index.js +2 -0
  34. package/dist/services/meta-service.d.ts +15 -2
  35. package/dist/services/meta-service.js +51 -1
  36. package/dist/services/queue-webhook-service.d.ts +55 -0
  37. package/dist/services/queue-webhook-service.js +136 -0
  38. package/dist/services/scope-service.d.ts +67 -0
  39. package/dist/services/scope-service.js +1 -0
  40. package/dist/services/typed-secret-service.d.ts +8 -0
  41. package/dist/services/typed-secret-service.js +39 -4
  42. package/dist/services/webhook-service.d.ts +140 -0
  43. package/dist/services/webhook-service.js +44 -0
  44. package/dist/services/workflow-service.d.ts +7 -0
  45. package/dist/types/core.types.d.ts +34 -6
  46. package/dist/types/state.types.d.ts +20 -2
  47. package/dist/utils/hmac.d.ts +16 -0
  48. package/dist/utils/hmac.js +26 -0
  49. package/dist/utils/safe-fetch.d.ts +51 -0
  50. package/dist/utils/safe-fetch.js +192 -0
  51. package/dist/wirings/ai-agent/ai-agent-agui.d.ts +84 -0
  52. package/dist/wirings/ai-agent/ai-agent-agui.js +305 -0
  53. package/dist/wirings/ai-agent/ai-agent-prepare.d.ts +41 -1
  54. package/dist/wirings/ai-agent/ai-agent-prepare.js +146 -6
  55. package/dist/wirings/ai-agent/ai-agent-runner.js +6 -1
  56. package/dist/wirings/ai-agent/ai-agent-stream.js +61 -10
  57. package/dist/wirings/ai-agent/ai-agent.types.d.ts +27 -1
  58. package/dist/wirings/ai-agent/index.d.ts +1 -0
  59. package/dist/wirings/ai-agent/index.js +1 -0
  60. package/dist/wirings/ai-agent/voice-input.d.ts +1 -0
  61. package/dist/wirings/ai-agent/voice-input.js +2 -38
  62. package/dist/wirings/ai-agent/voice-output.d.ts +1 -0
  63. package/dist/wirings/channel/channel-common.js +0 -1
  64. package/dist/wirings/channel/channel-handler.js +1 -4
  65. package/dist/wirings/channel/channel-middleware-runner.d.ts +12 -0
  66. package/dist/wirings/channel/channel-middleware-runner.js +34 -19
  67. package/dist/wirings/channel/channel.types.d.ts +2 -6
  68. package/dist/wirings/cli/cli-runner.js +0 -2
  69. package/dist/wirings/cli/cli.types.d.ts +1 -2
  70. package/dist/wirings/http/http-routes.js +0 -3
  71. package/dist/wirings/http/http-runner.d.ts +9 -29
  72. package/dist/wirings/http/http-runner.js +13 -35
  73. package/dist/wirings/http/http.types.d.ts +1 -9
  74. package/dist/wirings/mcp/mcp-runner.js +0 -2
  75. package/dist/wirings/mcp/mcp.types.d.ts +5 -11
  76. package/dist/wirings/oauth2/index.d.ts +0 -3
  77. package/dist/wirings/oauth2/index.js +1 -2
  78. package/dist/wirings/rpc/addon-runner.d.ts +28 -0
  79. package/dist/wirings/rpc/addon-runner.js +173 -0
  80. package/dist/wirings/rpc/index.d.ts +5 -1
  81. package/dist/wirings/rpc/index.js +3 -1
  82. package/dist/wirings/rpc/remote-addon-auth.d.ts +26 -0
  83. package/dist/wirings/rpc/remote-addon-auth.js +43 -0
  84. package/dist/wirings/rpc/rpc-runner.d.ts +19 -0
  85. package/dist/wirings/rpc/rpc-runner.js +109 -3
  86. package/dist/wirings/rpc/rpc-types.d.ts +15 -1
  87. package/dist/wirings/rpc/wire-addon.js +9 -0
  88. package/dist/wirings/rpc/wire-remote-addon.d.ts +47 -0
  89. package/dist/wirings/rpc/wire-remote-addon.js +19 -0
  90. package/dist/wirings/scope/index.d.ts +3 -0
  91. package/dist/wirings/scope/index.js +2 -0
  92. package/dist/wirings/scope/scope.types.d.ts +39 -0
  93. package/dist/wirings/scope/scope.types.js +1 -0
  94. package/dist/wirings/scope/validate-scope-definitions.d.ts +16 -0
  95. package/dist/wirings/scope/validate-scope-definitions.js +76 -0
  96. package/dist/wirings/scope/wire-scope.d.ts +33 -0
  97. package/dist/wirings/scope/wire-scope.js +32 -0
  98. package/dist/wirings/workflow/dsl/index.d.ts +1 -1
  99. package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +53 -1
  100. package/dist/wirings/workflow/graph/graph-node.d.ts +2 -0
  101. package/dist/wirings/workflow/graph/graph-runner.js +3 -0
  102. package/dist/wirings/workflow/graph/wire-workflow-graph.d.ts +6 -0
  103. package/dist/wirings/workflow/graph/wire-workflow-graph.js +1 -0
  104. package/dist/wirings/workflow/graph/workflow-graph.types.d.ts +5 -0
  105. package/dist/wirings/workflow/index.d.ts +2 -2
  106. package/dist/wirings/workflow/index.js +1 -1
  107. package/dist/wirings/workflow/pikku-workflow-service.d.ts +51 -1
  108. package/dist/wirings/workflow/pikku-workflow-service.js +171 -0
  109. package/dist/wirings/workflow/workflow.types.d.ts +1 -1
  110. package/package.json +5 -1
  111. package/run-tests.sh +1 -0
  112. package/src/dev/hot-reload.ts +13 -68
  113. package/src/dev/module-runner.test.ts +169 -0
  114. package/src/dev/module-runner.ts +103 -0
  115. package/src/dev/reload-meta.test.ts +31 -2
  116. package/src/dev/reload-meta.ts +26 -0
  117. package/src/errors/errors.ts +24 -0
  118. package/src/function/function-runner.test.ts +276 -142
  119. package/src/function/function-runner.ts +52 -145
  120. package/src/function/functions.types.ts +28 -38
  121. package/src/index.ts +8 -6
  122. package/src/middleware/index.ts +1 -5
  123. package/src/permissions.test.ts +153 -372
  124. package/src/permissions.ts +139 -259
  125. package/src/pikku-state.ts +0 -2
  126. package/src/scopes.test.ts +167 -0
  127. package/src/scopes.ts +69 -0
  128. package/src/services/ai-embedding-service.ts +31 -0
  129. package/src/services/credential-wire-service.ts +10 -4
  130. package/src/services/index.ts +20 -0
  131. package/src/services/meta-service.ts +68 -3
  132. package/src/services/queue-webhook-service.test.ts +408 -0
  133. package/src/services/queue-webhook-service.ts +182 -0
  134. package/src/services/scope-service.ts +75 -0
  135. package/src/services/typed-secret-service.test.ts +35 -0
  136. package/src/services/typed-secret-service.ts +39 -4
  137. package/src/services/webhook-service.ts +180 -0
  138. package/src/services/workflow-service.ts +7 -0
  139. package/src/types/core.types.ts +42 -14
  140. package/src/types/state.types.ts +23 -3
  141. package/src/utils/hmac.ts +27 -0
  142. package/src/utils/safe-fetch.test.ts +373 -0
  143. package/src/utils/safe-fetch.ts +213 -0
  144. package/src/wirings/ai-agent/ai-agent-agui.test.ts +1127 -0
  145. package/src/wirings/ai-agent/ai-agent-agui.ts +386 -0
  146. package/src/wirings/ai-agent/ai-agent-prepare.test.ts +308 -0
  147. package/src/wirings/ai-agent/ai-agent-prepare.ts +192 -8
  148. package/src/wirings/ai-agent/ai-agent-runner.test.ts +48 -1
  149. package/src/wirings/ai-agent/ai-agent-runner.ts +21 -0
  150. package/src/wirings/ai-agent/ai-agent-stream.test.ts +198 -10
  151. package/src/wirings/ai-agent/ai-agent-stream.ts +84 -10
  152. package/src/wirings/ai-agent/ai-agent.types.ts +28 -0
  153. package/src/wirings/ai-agent/index.ts +1 -0
  154. package/src/wirings/ai-agent/voice-input.ts +6 -35
  155. package/src/wirings/channel/channel-common.ts +0 -1
  156. package/src/wirings/channel/channel-handler.ts +1 -9
  157. package/src/wirings/channel/channel-middleware-runner.test.ts +28 -2
  158. package/src/wirings/channel/channel-middleware-runner.ts +39 -27
  159. package/src/wirings/channel/channel.types.ts +0 -6
  160. package/src/wirings/cli/cli-runner.ts +0 -2
  161. package/src/wirings/cli/cli.types.ts +0 -2
  162. package/src/wirings/http/http-routes.ts +0 -3
  163. package/src/wirings/http/http-runner.test.ts +1 -11
  164. package/src/wirings/http/http-runner.ts +19 -40
  165. package/src/wirings/http/http.types.ts +1 -10
  166. package/src/wirings/mcp/mcp-runner.ts +0 -2
  167. package/src/wirings/mcp/mcp.types.ts +3 -11
  168. package/src/wirings/oauth2/index.ts +0 -3
  169. package/src/wirings/rpc/addon-runner.ts +233 -0
  170. package/src/wirings/rpc/index.ts +17 -1
  171. package/src/wirings/rpc/remote-addon-auth.ts +69 -0
  172. package/src/wirings/rpc/rpc-runner.test.ts +319 -16
  173. package/src/wirings/rpc/rpc-runner.ts +155 -3
  174. package/src/wirings/rpc/rpc-types.ts +16 -5
  175. package/src/wirings/rpc/wire-addon.test.ts +6 -3
  176. package/src/wirings/rpc/wire-addon.ts +9 -0
  177. package/src/wirings/rpc/wire-remote-addon.ts +57 -0
  178. package/src/wirings/scope/index.ts +14 -0
  179. package/src/wirings/scope/scope.test.ts +135 -0
  180. package/src/wirings/scope/scope.types.ts +44 -0
  181. package/src/wirings/scope/validate-scope-definitions.ts +110 -0
  182. package/src/wirings/scope/wire-scope.ts +34 -0
  183. package/src/wirings/workflow/dsl/index.ts +4 -0
  184. package/src/wirings/workflow/dsl/workflow-dsl.types.ts +61 -0
  185. package/src/wirings/workflow/graph/graph-node.ts +2 -0
  186. package/src/wirings/workflow/graph/graph-runner.test.ts +58 -0
  187. package/src/wirings/workflow/graph/graph-runner.ts +3 -0
  188. package/src/wirings/workflow/graph/wire-workflow-graph.ts +7 -0
  189. package/src/wirings/workflow/graph/workflow-graph.types.ts +5 -0
  190. package/src/wirings/workflow/index.ts +5 -0
  191. package/src/wirings/workflow/pikku-workflow-service.test.ts +341 -0
  192. package/src/wirings/workflow/pikku-workflow-service.ts +236 -0
  193. package/src/wirings/workflow/workflow-step-session.test.ts +21 -5
  194. package/src/wirings/workflow/workflow.types.ts +4 -0
  195. package/tsconfig.tsbuildinfo +1 -1
  196. package/src/wirings/oauth2/oauth2-client.test.ts +0 -930
  197. package/src/wirings/oauth2/oauth2-client.ts +0 -331
  198. package/src/wirings/oauth2/oauth2-routes.ts +0 -234
@@ -49,6 +49,11 @@ export interface GraphNodeConfig<NodeIds extends string = string> {
49
49
  retries?: number;
50
50
  /** Delay between retries — milliseconds, duration string, or 'exponential' */
51
51
  retryDelay?: string | number;
52
+ /**
53
+ * Free-text documentation for this node. Non-semantic: excluded from the
54
+ * graph topology hash, so editing a note never marks the workflow as changed.
55
+ */
56
+ notes?: string;
52
57
  }
53
58
  /**
54
59
  * Graph wire context - available to functions running in a workflow graph
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Workflow module exports
3
3
  */
4
- export { PikkuWorkflowService, WorkflowCancelledException, WorkflowSuspendedException, WorkflowDispatchException, WorkflowNotFoundError, WorkflowRunNotFoundError, DEFAULT_STEP_RETRIES, } from './pikku-workflow-service.js';
4
+ export { PikkuWorkflowService, WorkflowCancelledException, WorkflowSuspendedException, WorkflowDispatchException, WorkflowNotFoundError, WorkflowRunNotFoundError, WorkflowApprovalResolvedError, DEFAULT_STEP_RETRIES, } from './pikku-workflow-service.js';
5
5
  export { deriveInvocationId, uuidv5 } from './workflow-invocation-id.js';
6
6
  export { buildRunTimeline, reconstructStateAt, reconstructFinalState, } from './run-timeline.js';
7
7
  export type { RunTimeline, RunTimelineEvent, ReconstructedRunState, ReconstructedStep, RunPhase, } from './run-timeline.js';
@@ -12,4 +12,4 @@ export { validateWorkflowWiring, computeEntryNodeIds, } from './graph/graph-vali
12
12
  export { pikkuWorkflowWorkerFunc, pikkuWorkflowOrchestratorFunc, pikkuWorkflowSleeperFunc, } from './workflow-queue-workers.js';
13
13
  export type { WorkflowStepInput as WorkflowStepQueueInput, PikkuWorkflowOrchestratorInput, PikkuWorkflowSleeperInput, } from './workflow-queue-workers.js';
14
14
  export type { WorkflowService, WorkflowServiceConfig, WorkflowPlannedStep, WorkflowRunWire, WorkflowStatus, WorkflowVersionStatus, StepStatus, WorkflowRun, WorkflowRunStatus, StepState, WorkflowRunService, WorkflowRunMirror, CoreWorkflow, PikkuWorkflow, ContextVariable, WorkflowContext, WorkflowsMeta, WorkflowRuntimeMeta, WorkflowsRuntimeMeta, WorkflowStepInput, WorkflowOrchestratorInput, WorkflowSleeperInput, } from './workflow.types.js';
15
- export type { WorkflowStepOptions, WorkflowWireDoRPC, WorkflowWireDoInline, WorkflowWireSleep, WorkflowWireSuspend, InputSource, OutputBinding, RpcStepMeta, SimpleCondition, Condition, BranchCase, BranchStepMeta, ParallelGroupStepMeta, FanoutStepMeta, ReturnStepMeta, InlineStepMeta, SleepStepMeta, CancelStepMeta, SuspendStepMeta, SetStepMeta, SwitchCaseMeta, SwitchStepMeta, FilterStepMeta, ArrayPredicateStepMeta, WorkflowStepMeta, WorkflowStepWire, PikkuWorkflowWire, PikkuScenarioWire, } from './workflow.types.js';
15
+ export type { WorkflowStepOptions, WorkflowWireDoRPC, WorkflowWireDoInline, WorkflowWireSleep, WorkflowWireSuspend, WorkflowWireApproval, WorkflowApprovalOptions, ApprovalOutcome, InputSource, OutputBinding, RpcStepMeta, SimpleCondition, Condition, BranchCase, BranchStepMeta, ParallelGroupStepMeta, FanoutStepMeta, ReturnStepMeta, InlineStepMeta, SleepStepMeta, CancelStepMeta, SuspendStepMeta, ApprovalStepMeta, SetStepMeta, SwitchCaseMeta, SwitchStepMeta, FilterStepMeta, ArrayPredicateStepMeta, WorkflowStepMeta, WorkflowStepWire, PikkuWorkflowWire, PikkuScenarioWire, } from './workflow.types.js';
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Workflow module exports
3
3
  */
4
- export { PikkuWorkflowService, WorkflowCancelledException, WorkflowSuspendedException, WorkflowDispatchException, WorkflowNotFoundError, WorkflowRunNotFoundError, DEFAULT_STEP_RETRIES, } from './pikku-workflow-service.js';
4
+ export { PikkuWorkflowService, WorkflowCancelledException, WorkflowSuspendedException, WorkflowDispatchException, WorkflowNotFoundError, WorkflowRunNotFoundError, WorkflowApprovalResolvedError, DEFAULT_STEP_RETRIES, } from './pikku-workflow-service.js';
5
5
  export { deriveInvocationId, uuidv5 } from './workflow-invocation-id.js';
6
6
  // Time-travel: reconstruct run state at any point from durable history
7
7
  export { buildRunTimeline, reconstructStateAt, reconstructFinalState, } from './run-timeline.js';
@@ -1,5 +1,5 @@
1
1
  import type { SerializedError } from '../../types/core.types.js';
2
- import type { PikkuScenarioWire, StepState, StepStatus, WorkflowPlannedStep, WorkflowRun, WorkflowRunMirror, WorkflowRunStatus, WorkflowRunWire, WorkflowStatus, WorkflowVersionStatus, WorkflowStepOptions } from './workflow.types.js';
2
+ import type { ApprovalOutcome, PikkuScenarioWire, StepState, StepStatus, WorkflowPlannedStep, WorkflowRun, WorkflowRunMirror, WorkflowRunStatus, WorkflowRunWire, WorkflowStatus, WorkflowVersionStatus, WorkflowStepOptions } from './workflow.types.js';
3
3
  import type { WorkflowService } from '../../services/workflow-service.js';
4
4
  import type { ScenarioActors } from '../../services/scenario-actors-service.js';
5
5
  import { PikkuError } from '../../errors/error-handler.js';
@@ -70,6 +70,19 @@ export declare class WorkflowRunFailedError extends PikkuError {
70
70
  export declare class WorkflowRunCancelledError extends PikkuError {
71
71
  constructor();
72
72
  }
73
+ /**
74
+ * A decision arrived for an approval gate that has already resolved. The gate
75
+ * caches its outcome as the step result and never re-reads run state, so the
76
+ * decision could not take effect — it is rejected rather than accepted and
77
+ * dropped.
78
+ */
79
+ export declare class WorkflowApprovalResolvedError extends PikkuError {
80
+ payload: {
81
+ reason: string;
82
+ outcome: ApprovalOutcome<unknown>['status'];
83
+ };
84
+ constructor(reason: string, outcome: ApprovalOutcome<unknown>['status']);
85
+ }
73
86
  export declare class WorkflowServiceNotInitialized extends Error {
74
87
  }
75
88
  export declare class WorkflowStepNameNotString extends Error {
@@ -446,6 +459,43 @@ export declare abstract class PikkuWorkflowService implements WorkflowService {
446
459
  */
447
460
  private getSuspendStepName;
448
461
  private suspendStep;
462
+ /**
463
+ * Wake a run later by enqueuing a delayed orchestrator pass. Deliberately NOT
464
+ * {@link scheduleSleep}: that resolves the step it is given, which for an
465
+ * approval would resolve the gate itself. This only nudges the run to replay
466
+ * and re-evaluate — the gate stays the sole judge of its own outcome.
467
+ *
468
+ * Best-effort by design. Expiry is decided from the recorded deadline on
469
+ * replay, so losing this wake costs liveness (the run sits until something
470
+ * else resumes it), never correctness.
471
+ */
472
+ private scheduleRunWake;
473
+ /**
474
+ * Durable step name for an approval gate. Namespaced separately from suspend
475
+ * so the two can't collide, and derived from `reason` for the same reason
476
+ * {@link getSuspendStepName} is: it must be stable across replays.
477
+ */
478
+ private getApprovalStepName;
479
+ /**
480
+ * Run-state key holding an approval gate's record. Hex-encoded because the
481
+ * Mongo backend restricts state keys to `/^[a-zA-Z0-9_]+$/` and a `reason` is
482
+ * arbitrary human text. One key per gate, so two gates resolving concurrently
483
+ * can't clobber each other through a read-modify-write.
484
+ */
485
+ private approvalStateKey;
486
+ /**
487
+ * Record a decision against an approval gate and wake the run. Called from
488
+ * outside the workflow (an HTTP route, an RPC), so the schema value is NOT in
489
+ * scope here — the payload is stored raw and validated on replay inside the
490
+ * workflow body, which is the only place the schema exists. An invalid payload
491
+ * therefore leaves the gate closed rather than failing the run.
492
+ *
493
+ * `reason` addresses the first reach of that gate. An approval reached more
494
+ * than once under the same reason (e.g. in a loop) gets `#N`-suffixed step
495
+ * rows that this cannot currently target.
496
+ */
497
+ approveStep(runId: string, reason: string, decision: unknown): Promise<void>;
498
+ private approvalStep;
449
499
  createWorkflowWire(name: string, runId: string, rpcService: any, addonNamespace?: string | null): PikkuScenarioWire;
450
500
  private verifyStepName;
451
501
  private getConfig;
@@ -142,6 +142,23 @@ addError(WorkflowRunCancelledError, {
142
142
  status: 409,
143
143
  message: 'Workflow was cancelled.',
144
144
  });
145
+ /**
146
+ * A decision arrived for an approval gate that has already resolved. The gate
147
+ * caches its outcome as the step result and never re-reads run state, so the
148
+ * decision could not take effect — it is rejected rather than accepted and
149
+ * dropped.
150
+ */
151
+ export class WorkflowApprovalResolvedError extends PikkuError {
152
+ payload;
153
+ constructor(reason, outcome) {
154
+ super(`Approval already ${outcome}: ${reason}`);
155
+ this.payload = { reason, outcome };
156
+ }
157
+ }
158
+ addError(WorkflowApprovalResolvedError, {
159
+ status: 409,
160
+ message: 'Approval has already been resolved.',
161
+ });
145
162
  export class WorkflowServiceNotInitialized extends Error {
146
163
  }
147
164
  export class WorkflowStepNameNotString extends Error {
@@ -1381,6 +1398,156 @@ export class PikkuWorkflowService {
1381
1398
  throw new WorkflowSuspendedException(runId, reason);
1382
1399
  });
1383
1400
  }
1401
+ /**
1402
+ * Wake a run later by enqueuing a delayed orchestrator pass. Deliberately NOT
1403
+ * {@link scheduleSleep}: that resolves the step it is given, which for an
1404
+ * approval would resolve the gate itself. This only nudges the run to replay
1405
+ * and re-evaluate — the gate stays the sole judge of its own outcome.
1406
+ *
1407
+ * Best-effort by design. Expiry is decided from the recorded deadline on
1408
+ * replay, so losing this wake costs liveness (the run sits until something
1409
+ * else resumes it), never correctness.
1410
+ */
1411
+ async scheduleRunWake(runId, delay) {
1412
+ try {
1413
+ const queueService = this.verifyQueueService();
1414
+ const run = await this.getRun(runId);
1415
+ if (!run?.workflow)
1416
+ return;
1417
+ await queueService.add(this.getOrchestratorQueueName(run.workflow), { runId }, { ...this.resolveStepJobOptions(), delay });
1418
+ }
1419
+ catch (error) {
1420
+ this.logger?.warn(`Failed to schedule approval expiry wake for run ${runId}; expiry will still resolve on the next replay`, error);
1421
+ }
1422
+ }
1423
+ /**
1424
+ * Durable step name for an approval gate. Namespaced separately from suspend
1425
+ * so the two can't collide, and derived from `reason` for the same reason
1426
+ * {@link getSuspendStepName} is: it must be stable across replays.
1427
+ */
1428
+ getApprovalStepName(reason) {
1429
+ return `__workflow_approval:${reason}`;
1430
+ }
1431
+ /**
1432
+ * Run-state key holding an approval gate's record. Hex-encoded because the
1433
+ * Mongo backend restricts state keys to `/^[a-zA-Z0-9_]+$/` and a `reason` is
1434
+ * arbitrary human text. One key per gate, so two gates resolving concurrently
1435
+ * can't clobber each other through a read-modify-write.
1436
+ */
1437
+ approvalStateKey(stepName) {
1438
+ let hex = '';
1439
+ for (const byte of new TextEncoder().encode(stepName)) {
1440
+ hex += byte.toString(16).padStart(2, '0');
1441
+ }
1442
+ return `__approval_${hex}`;
1443
+ }
1444
+ /**
1445
+ * Record a decision against an approval gate and wake the run. Called from
1446
+ * outside the workflow (an HTTP route, an RPC), so the schema value is NOT in
1447
+ * scope here — the payload is stored raw and validated on replay inside the
1448
+ * workflow body, which is the only place the schema exists. An invalid payload
1449
+ * therefore leaves the gate closed rather than failing the run.
1450
+ *
1451
+ * `reason` addresses the first reach of that gate. An approval reached more
1452
+ * than once under the same reason (e.g. in a loop) gets `#N`-suffixed step
1453
+ * rows that this cannot currently target.
1454
+ */
1455
+ async approveStep(runId, reason, decision) {
1456
+ const stepName = this.getApprovalStepName(reason);
1457
+ const stateKey = this.approvalStateKey(stepName);
1458
+ // A resolved gate returns its cached step result and never re-reads state,
1459
+ // so a decision recorded now would be silently discarded — most obviously
1460
+ // when it loses the race with expiry. Reject instead, so the approver
1461
+ // learns their decision did not land.
1462
+ let resolved;
1463
+ try {
1464
+ resolved = await this.getStepState(runId, stepName);
1465
+ }
1466
+ catch {
1467
+ // No step row yet: the run has not reached the gate. Recording a decision
1468
+ // ahead of it is legitimate — the gate picks it up on arrival.
1469
+ }
1470
+ if (resolved?.stepId && resolved.status === 'succeeded') {
1471
+ const outcome = resolved.result;
1472
+ throw new WorkflowApprovalResolvedError(reason, outcome?.status ?? 'decided');
1473
+ }
1474
+ const state = await this.getRunState(runId);
1475
+ const record = (state[stateKey] ?? {});
1476
+ await this.updateRunState(runId, stateKey, {
1477
+ ...record,
1478
+ decision,
1479
+ decidedAt: new Date().toISOString(),
1480
+ error: undefined,
1481
+ });
1482
+ await this.resumeWorkflow(runId);
1483
+ }
1484
+ async approvalStep(runId, reason, options) {
1485
+ const fromStepName = this.lastStepName(runId);
1486
+ const approvalStepName = this.nextStepKey(runId, this.getApprovalStepName(reason));
1487
+ return await this.withStepLock(runId, approvalStepName, async () => {
1488
+ let stepState;
1489
+ try {
1490
+ stepState = await this.getStepState(runId, approvalStepName);
1491
+ }
1492
+ catch {
1493
+ stepState = await this.insertStepState(runId, approvalStepName, 'pikkuWorkflowApproval', { reason, expiry: options.expiry }, undefined, fromStepName);
1494
+ }
1495
+ if (!stepState.stepId) {
1496
+ stepState = await this.insertStepState(runId, approvalStepName, 'pikkuWorkflowApproval', { reason, expiry: options.expiry }, undefined, fromStepName);
1497
+ }
1498
+ // Unlike suspend, `succeeded` here means a decision (or expiry) was
1499
+ // actually resolved, and the step result IS the return channel.
1500
+ if (stepState.status === 'succeeded') {
1501
+ return stepState.result;
1502
+ }
1503
+ const stateKey = this.approvalStateKey(approvalStepName);
1504
+ let record = ((await this.getRunState(runId))[stateKey] ?? {});
1505
+ if (stepState.status === 'pending') {
1506
+ await this.setStepRunning(stepState.stepId);
1507
+ // First reach: stamp the deadline and nudge the run awake when it
1508
+ // passes. The deadline is what's authoritative — see below.
1509
+ if (options.expiry !== undefined && !record.expiresAt) {
1510
+ const expiresAt = new Date(Date.now() + getDurationInMilliseconds(options.expiry)).toISOString();
1511
+ record = { ...record, expiresAt };
1512
+ await this.updateRunState(runId, stateKey, record);
1513
+ await this.scheduleRunWake(runId, getDurationInMilliseconds(options.expiry));
1514
+ }
1515
+ }
1516
+ if (record.decision !== undefined) {
1517
+ const validation = await options.schema['~standard'].validate(record.decision);
1518
+ if (validation.issues) {
1519
+ // Drop the bad decision and re-close the gate, leaving the failure
1520
+ // legible to whoever tries next. Failing the run instead would let any
1521
+ // caller kill a workflow with a malformed payload.
1522
+ await this.updateRunState(runId, stateKey, {
1523
+ ...record,
1524
+ decision: undefined,
1525
+ decidedAt: undefined,
1526
+ error: validation.issues.map((issue) => ({
1527
+ message: issue.message,
1528
+ path: issue.path?.map((segment) => typeof segment === 'object' ? segment.key : segment),
1529
+ })),
1530
+ });
1531
+ throw new WorkflowSuspendedException(runId, reason);
1532
+ }
1533
+ const outcome = {
1534
+ status: 'decided',
1535
+ data: validation.value,
1536
+ };
1537
+ await this.setStepResult(stepState.stepId, outcome);
1538
+ return outcome;
1539
+ }
1540
+ // Expiry is decided by comparing against the recorded deadline rather than
1541
+ // by the timer having fired, so a duplicate, late, or dropped timer all
1542
+ // produce the same answer.
1543
+ if (record.expiresAt && Date.now() >= Date.parse(record.expiresAt)) {
1544
+ const outcome = { status: 'expired' };
1545
+ await this.setStepResult(stepState.stepId, outcome);
1546
+ return outcome;
1547
+ }
1548
+ throw new WorkflowSuspendedException(runId, reason);
1549
+ });
1550
+ }
1384
1551
  createWorkflowWire(name, runId, rpcService, addonNamespace) {
1385
1552
  const workflowWire = {
1386
1553
  name,
@@ -1492,6 +1659,10 @@ export class PikkuWorkflowService {
1492
1659
  this.verifyStepName(reason);
1493
1660
  await this.suspendStep(runId, reason);
1494
1661
  },
1662
+ approval: (async (reason, options) => {
1663
+ this.verifyStepName(reason);
1664
+ return await this.approvalStep(runId, reason, options);
1665
+ }),
1495
1666
  runScheduledTask: async (taskName) => {
1496
1667
  await runScheduledTask({ name: taskName });
1497
1668
  },
@@ -1,7 +1,7 @@
1
1
  import type { SerializedError, CommonWireMeta } from '../../types/core.types.js';
2
2
  import type { CorePikkuFunctionConfig } from '../../function/functions.types.js';
3
3
  export type { WorkflowService } from '../../services/workflow-service.js';
4
- export type { WorkflowStepOptions, WorkflowExpectEventuallyOptions, WorkflowExpectErrorOptions, WorkflowExpectServiceOptions, WorkflowWireDoRPC, WorkflowWireDoInline, WorkflowWireSleep, WorkflowWireSuspend, InputSource, OutputBinding, RpcStepMeta, SimpleCondition, Condition, BranchCase, BranchStepMeta, ParallelGroupStepMeta, FanoutStepMeta, ReturnStepMeta, InlineStepMeta, SleepStepMeta, CancelStepMeta, SuspendStepMeta, SetStepMeta, SwitchCaseMeta, SwitchStepMeta, FilterStepMeta, ArrayPredicateStepMeta, WorkflowStepMeta, WorkflowStepWire, PikkuWorkflowWire, PikkuScenarioWire, } from './dsl/workflow-dsl.types.js';
4
+ export type { WorkflowStepOptions, WorkflowExpectEventuallyOptions, WorkflowExpectErrorOptions, WorkflowExpectServiceOptions, WorkflowWireDoRPC, WorkflowWireDoInline, WorkflowWireSleep, WorkflowWireSuspend, WorkflowWireApproval, WorkflowApprovalOptions, ApprovalOutcome, InputSource, OutputBinding, RpcStepMeta, SimpleCondition, Condition, BranchCase, BranchStepMeta, ParallelGroupStepMeta, FanoutStepMeta, ReturnStepMeta, InlineStepMeta, SleepStepMeta, CancelStepMeta, SuspendStepMeta, ApprovalStepMeta, SetStepMeta, SwitchCaseMeta, SwitchStepMeta, FilterStepMeta, ArrayPredicateStepMeta, WorkflowStepMeta, WorkflowStepWire, PikkuWorkflowWire, PikkuScenarioWire, } from './dsl/workflow-dsl.types.js';
5
5
  import type { WorkflowStepMeta } from './dsl/workflow-dsl.types.js';
6
6
  export interface WorkflowRunWire {
7
7
  type: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikku/core",
3
- "version": "0.12.62",
3
+ "version": "0.12.64",
4
4
  "author": "yasser.fadl@gmail.com",
5
5
  "license": "MIT",
6
6
  "module": "dist/index.js",
@@ -41,6 +41,7 @@
41
41
  "./cli/channel": "./dist/wirings/cli/channel/index.js",
42
42
  "./node": "./dist/wirings/node/index.js",
43
43
  "./credential": "./dist/wirings/credential/index.js",
44
+ "./scope": "./dist/wirings/scope/index.js",
44
45
  "./secret": "./dist/wirings/secret/index.js",
45
46
  "./variable": "./dist/wirings/variable/index.js",
46
47
  "./oauth2": "./dist/wirings/oauth2/index.js",
@@ -53,6 +54,7 @@
53
54
  "./services/temporary-file-service": "./dist/services/temporary-file-service.js",
54
55
  "./services/gopass-secrets": "./dist/services/gopass-secrets.js",
55
56
  "./crypto-utils": "./dist/crypto-utils.js",
57
+ "./hmac": "./dist/utils/hmac.js",
56
58
  "./internal": "./dist/internal.js",
57
59
  "./schema": "./dist/schema.js",
58
60
  "./testing": "./dist/testing/index.js",
@@ -67,7 +69,9 @@
67
69
  "picoquery": "^2.5.0"
68
70
  },
69
71
  "devDependencies": {
72
+ "@ag-ui/core": "0.0.57",
70
73
  "@types/node": "^24.11.0",
74
+ "esbuild": "~0.27.0",
71
75
  "tsx": "^4.21.0",
72
76
  "typescript": "^6.0.3"
73
77
  },
package/run-tests.sh CHANGED
@@ -53,6 +53,7 @@ fi
53
53
 
54
54
  if [ "$coverage_mode" = true ]; then
55
55
  node_cmd+=(--test-coverage-include="src/**/*.{ts,js}" --test-coverage-exclude="**/dist/**" --experimental-test-coverage --test-reporter=lcov --test-reporter-destination=lcov.info)
56
+ export PIKKU_TEST_COVERAGE=1
56
57
  fi
57
58
 
58
59
  # Execute the node command with the expanded list of files
@@ -1,9 +1,6 @@
1
1
  import { watch, type FSWatcher } from 'node:fs'
2
- import { stat, readFile, copyFile, rm } from 'node:fs/promises'
3
- import { basename, dirname, join, resolve, relative } from 'node:path'
4
- import { pathToFileURL } from 'node:url'
5
-
6
- import { register } from 'tsx/esm/api'
2
+ import { stat } from 'node:fs/promises'
3
+ import { basename, join, resolve, relative } from 'node:path'
7
4
 
8
5
  import { pikkuState } from '../pikku-state.js'
9
6
  import { clearMiddlewareCache } from '../middleware-runner.js'
@@ -12,6 +9,7 @@ import { clearChannelMiddlewareCache } from '../wirings/channel/channel-middlewa
12
9
  import { httpRouter } from '../wirings/http/routers/http-router.js'
13
10
  import type { Logger } from '../services/logger.js'
14
11
  import type { CorePikkuFunctionConfig } from '../function/functions.types.js'
12
+ import { createModuleRunner } from './module-runner.js'
15
13
 
16
14
  export * from './reload-meta.js'
17
15
 
@@ -54,67 +52,19 @@ const findCompiledFile = async (
54
52
  return null
55
53
  }
56
54
 
57
- // Use data: URLs to import modules. This bypasses TypeScript loaders
58
- // (e.g. tsx) that intercept file:// imports and break dynamic ESM loading.
59
- // Each import gets unique content so there's no module cache to worry about.
60
- let tsxRegistered = false
61
-
62
- const ensureTsxRegistered = () => {
63
- if (tsxRegistered) return
64
- register()
65
- tsxRegistered = true
66
- }
67
-
68
- let tempCounter = 0
69
-
70
- const reimportModule = async (
71
- filePath: string,
72
- useTsx = false
73
- ): Promise<Record<string, unknown> | null> => {
74
- try {
75
- if (useTsx) {
76
- // Import a uniquely-named sibling copy: a `?t=` query does NOT bust
77
- // the cache on either runtime (Bun keys module identity on the bare
78
- // path; tsx's transform cache keys on the file path too), so a fresh
79
- // path is the only reliable re-import. Same directory → identical
80
- // resolution for relative and package-`imports` (#…) specifiers. The
81
- // dot-prefix keeps it out of the watcher.
82
- if (!process.versions.bun) {
83
- // Node needs tsx's loader to import raw .ts; Bun imports it natively.
84
- ensureTsxRegistered()
85
- }
86
- const abs = resolve(filePath)
87
- const tempPath = join(
88
- dirname(abs),
89
- `.pikku-hot-${++tempCounter}-${basename(abs)}`
90
- )
91
- await copyFile(abs, tempPath)
92
- try {
93
- return await import(pathToFileURL(tempPath).href)
94
- } finally {
95
- await rm(tempPath, { force: true }).catch(() => {
96
- // Best-effort temp cleanup; a leftover dotfile is watcher-ignored.
97
- })
98
- }
99
- }
100
-
101
- const content = await readFile(resolve(filePath), 'utf-8')
102
- const dataUrl =
103
- 'data:text/javascript;base64,' + Buffer.from(content).toString('base64')
104
- return await import(dataUrl)
105
- } catch {
106
- return null
107
- }
108
- }
109
-
55
+ // Changed user files are re-run through an evictable module runner keyed by a
56
+ // stable path (createModuleRunner), NOT re-imported under a fresh URL. A
57
+ // fresh-URL import (a `data:` URL on Node, a uniquely-named temp sibling on
58
+ // Bun) permanently leaks a record in the native ESM loader map on every
59
+ // reload, OOMing long editing sessions; the runner overwrites a single slot so
60
+ // the previous module is collected. See ./module-runner.ts.
110
61
  const isWatchedTsFile = (filename: string): boolean => {
111
62
  return (
112
63
  filename.endsWith('.ts') &&
113
64
  !filename.endsWith('.test.ts') &&
114
65
  !filename.endsWith('.d.ts') &&
115
66
  !filename.endsWith('.gen.ts') &&
116
- // Hidden files: editor/sed atomic-write temps and our own hot-reload
117
- // sibling copies must never trigger a reload of themselves.
67
+ // Hidden files: editor/sed atomic-write temps must never trigger a reload.
118
68
  !basename(filename).startsWith('.')
119
69
  )
120
70
  }
@@ -135,6 +85,7 @@ export async function pikkuDevReloader(
135
85
  const watchers: FSWatcher[] = []
136
86
 
137
87
  const functionsMap = pikkuState(null, 'function', 'functions')
88
+ const moduleRunner = createModuleRunner()
138
89
 
139
90
  const handleFileChange = async (changedTsFile: string) => {
140
91
  const start = Date.now()
@@ -150,15 +101,8 @@ export async function pikkuDevReloader(
150
101
  absPikkuDir
151
102
  )
152
103
  const importPath = compiledFile ?? changedTsFile
153
- const usedTsxFallback = !compiledFile
154
-
155
- if (usedTsxFallback) {
156
- logger.debug(
157
- `Hot-reload using tsx fallback for: ${relative(process.cwd(), changedTsFile)}`
158
- )
159
- }
160
104
 
161
- const mod = await reimportModule(importPath, usedTsxFallback)
105
+ const mod = await moduleRunner.run(importPath)
162
106
  if (!mod) {
163
107
  logger.error(
164
108
  `Failed to import: ${relative(process.cwd(), importPath)} (keeping old code)`
@@ -260,6 +204,7 @@ export async function pikkuDevReloader(
260
204
  for (const watcher of watchers) {
261
205
  watcher.close()
262
206
  }
207
+ moduleRunner.clear()
263
208
  },
264
209
  // Drain the queue of recently re-imported files and import them again.
265
210
  // A dev-server watcher calls this AFTER its codegen pass has refreshed
@@ -0,0 +1,169 @@
1
+ import { describe, test, beforeEach, afterEach } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import v8 from 'node:v8'
4
+ import vm from 'node:vm'
5
+ import { mkdtemp, writeFile, rm } from 'node:fs/promises'
6
+ import { createRequire } from 'node:module'
7
+ import { join } from 'node:path'
8
+ import { pathToFileURL } from 'node:url'
9
+ import { tmpdir } from 'node:os'
10
+
11
+ import { createModuleRunner } from './module-runner.js'
12
+
13
+ // A forced-GC hook without launching the process with a flag: on Bun use the
14
+ // native collector; on Node flip --expose-gc on just long enough to grab `gc`.
15
+ const getGc = (): (() => void) => {
16
+ const bun = (globalThis as any).Bun
17
+ if (bun) return () => bun.gc(true)
18
+ v8.setFlagsFromString('--expose-gc')
19
+ const gc = vm.runInNewContext('gc') as () => void
20
+ v8.setFlagsFromString('--no-expose-gc')
21
+ return () => gc()
22
+ }
23
+
24
+ const heapUsedMb = () => process.memoryUsage().heapUsed / 1048576
25
+
26
+ // V8's --experimental-test-coverage retains coverage data for every compiled
27
+ // script for the life of the run, which pins the runner's per-reload compiled
28
+ // functions (and would equally pin the old leaky mechanism), so a heap-growth
29
+ // measurement can't discriminate a leak from a non-leak while it's on. run-tests.sh
30
+ // exports this marker in --coverage mode (the flag itself is not visible to the
31
+ // isolated test child). The single-slot registry guarantee below still runs
32
+ // unconditionally.
33
+ const UNDER_COVERAGE = process.env.PIKKU_TEST_COVERAGE === '1'
34
+
35
+ describe('createModuleRunner', { concurrency: false }, () => {
36
+ let tmpDir: string
37
+
38
+ beforeEach(async () => {
39
+ tmpDir = await mkdtemp(join(tmpdir(), 'pikku-module-runner-'))
40
+ await writeFile(
41
+ join(tmpDir, 'package.json'),
42
+ JSON.stringify({ type: 'module' })
43
+ )
44
+ })
45
+
46
+ afterEach(async () => {
47
+ await rm(tmpDir, { recursive: true, force: true })
48
+ })
49
+
50
+ test('runs a TS module and returns its exports', async () => {
51
+ const runner = createModuleRunner()
52
+ const file = join(tmpDir, 'todo.ts')
53
+ await writeFile(
54
+ file,
55
+ `interface Todo { id: string }
56
+ export const createTodo = { func: async (_s: any, d: Todo) => ({ id: d.id }) }`
57
+ )
58
+
59
+ const mod = await runner.run(file)
60
+ assert.ok(mod)
61
+ const createTodo = mod!.createTodo as {
62
+ func: (...a: any[]) => Promise<any>
63
+ }
64
+ assert.equal(typeof createTodo.func, 'function')
65
+ assert.deepEqual(await createTodo.func({}, { id: 'abc' }), { id: 'abc' })
66
+ })
67
+
68
+ test('top-level import side effects hit the live singleton dependency', async () => {
69
+ // Delegated imports must resolve to the SAME module instance the rest of
70
+ // the process holds, so a user file's wire* side effect mutates live state.
71
+ await writeFile(
72
+ join(tmpDir, 'registry.js'),
73
+ `export const registry = new Map()
74
+ export const wire = (name, cfg) => registry.set(name, cfg)`
75
+ )
76
+
77
+ const runner = createModuleRunner()
78
+ const userFile = join(tmpDir, 'wired.ts')
79
+ await writeFile(
80
+ userFile,
81
+ `import { wire } from './registry.js'
82
+ export const createTodo = { func: async () => ({ ok: true }) }
83
+ wire('createTodo', createTodo)`
84
+ )
85
+
86
+ const mod = await runner.run(userFile)
87
+ assert.ok(mod)
88
+
89
+ // Read the dependency through the same resolver the runner uses, so we
90
+ // observe the exact instance the user module's `import` bound to (using a
91
+ // separate `import()` would resolve a distinct copy under the tsx test
92
+ // loader and prove nothing about live-singleton delegation).
93
+ const dep = createRequire(pathToFileURL(userFile))('./registry.js') as {
94
+ registry: Map<string, unknown>
95
+ }
96
+ assert.equal(dep.registry.has('createTodo'), true)
97
+ assert.strictEqual(dep.registry.get('createTodo'), mod!.createTodo)
98
+ })
99
+
100
+ test('re-running the same path overwrites a single registry slot', async () => {
101
+ const runner = createModuleRunner()
102
+ const file = join(tmpDir, 'value.ts')
103
+
104
+ await writeFile(file, `export const value = { func: async () => 'v1' }`)
105
+ const first = await runner.run(file)
106
+ assert.equal(await (first!.value as any).func(), 'v1')
107
+
108
+ await writeFile(file, `export const value = { func: async () => 'v2' }`)
109
+ const second = await runner.run(file)
110
+ assert.equal(await (second!.value as any).func(), 'v2')
111
+
112
+ // Stable key: many reloads of one path never grow the registry.
113
+ for (let i = 0; i < 20; i++) await runner.run(file)
114
+ assert.equal(runner.size, 1)
115
+ })
116
+
117
+ test('returns null on a bad edit so the caller keeps old code', async () => {
118
+ const runner = createModuleRunner()
119
+ const file = join(tmpDir, 'broken.ts')
120
+ await writeFile(
121
+ file,
122
+ `export const oops = { func: async () => ( } ] syntax`
123
+ )
124
+ const mod = await runner.run(file)
125
+ assert.equal(mod, null)
126
+ })
127
+
128
+ test('editing and reimporting a module 200x does not leak memory', async () => {
129
+ const gc = getGc()
130
+ const runner = createModuleRunner()
131
+ const file = join(tmpDir, 'big.ts')
132
+ // A sizeable module edited on every reload (the real dev pattern). The old
133
+ // fresh-URL reimport left one of these in the native ESM loader map per
134
+ // edit (~0.3-1.3 MB each) — ~84 MB (Node) / ~222 MB (Bun) over 200 edits —
135
+ // and OOMed. The evictable runner overwrites a single stable slot, so the
136
+ // previous module is collected and heap stays bounded.
137
+ const payload = JSON.stringify(
138
+ Array.from({ length: 2000 }, (_, i) => ({ i, s: 'field_' + i }))
139
+ )
140
+ const write = (marker: number) =>
141
+ writeFile(
142
+ file,
143
+ `export const data = ${payload}
144
+ export const handler = { func: async () => data.length }
145
+ // edit ${marker}`
146
+ )
147
+
148
+ // Warm up (first compile allocates esbuild + shared caches), then baseline.
149
+ await write(0)
150
+ await runner.run(file)
151
+ gc()
152
+ const baseline = heapUsedMb()
153
+
154
+ for (let i = 1; i <= 200; i++) {
155
+ await write(i)
156
+ const mod = await runner.run(file)
157
+ assert.ok(mod)
158
+ }
159
+ gc()
160
+ const growth = heapUsedMb() - baseline
161
+
162
+ assert.equal(runner.size, 1)
163
+ if (UNDER_COVERAGE) return
164
+ assert.ok(
165
+ growth < 15,
166
+ `heap grew ${growth.toFixed(1)} MB over 200 edits (expected < 15 MB)`
167
+ )
168
+ })
169
+ })