@pikku/core 0.12.63 → 0.12.66

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 (225) hide show
  1. package/CHANGELOG.md +368 -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 +42 -27
  24. package/dist/permissions.js +101 -203
  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/testing/service-tests.js +37 -0
  46. package/dist/types/core.types.d.ts +34 -6
  47. package/dist/types/state.types.d.ts +20 -2
  48. package/dist/utils/hmac.d.ts +16 -0
  49. package/dist/utils/hmac.js +26 -0
  50. package/dist/utils/safe-fetch.d.ts +51 -0
  51. package/dist/utils/safe-fetch.js +192 -0
  52. package/dist/wirings/ai-agent/ai-agent-agui.d.ts +84 -0
  53. package/dist/wirings/ai-agent/ai-agent-agui.js +305 -0
  54. package/dist/wirings/ai-agent/ai-agent-prepare.d.ts +105 -1
  55. package/dist/wirings/ai-agent/ai-agent-prepare.js +249 -11
  56. package/dist/wirings/ai-agent/ai-agent-runner.js +10 -1
  57. package/dist/wirings/ai-agent/ai-agent-stream.js +88 -16
  58. package/dist/wirings/ai-agent/ai-agent.types.d.ts +56 -2
  59. package/dist/wirings/ai-agent/index.d.ts +2 -1
  60. package/dist/wirings/ai-agent/index.js +2 -1
  61. package/dist/wirings/ai-agent/voice-input.d.ts +1 -0
  62. package/dist/wirings/ai-agent/voice-input.js +5 -41
  63. package/dist/wirings/ai-agent/voice-output.d.ts +1 -0
  64. package/dist/wirings/channel/channel-common.js +0 -1
  65. package/dist/wirings/channel/channel-handler.js +1 -4
  66. package/dist/wirings/channel/channel-middleware-runner.d.ts +12 -0
  67. package/dist/wirings/channel/channel-middleware-runner.js +34 -19
  68. package/dist/wirings/channel/channel.types.d.ts +2 -6
  69. package/dist/wirings/cli/cli-runner.js +3 -2
  70. package/dist/wirings/cli/cli.types.d.ts +1 -2
  71. package/dist/wirings/cli/command-parser.d.ts +2 -0
  72. package/dist/wirings/cli/command-parser.js +59 -2
  73. package/dist/wirings/credential/credential.types.d.ts +14 -0
  74. package/dist/wirings/credential/validate-credential-definitions.js +1 -0
  75. package/dist/wirings/gateway/gateway-runner.js +100 -50
  76. package/dist/wirings/gateway/gateway.types.d.ts +8 -5
  77. package/dist/wirings/http/http-routes.js +0 -3
  78. package/dist/wirings/http/http-runner.d.ts +9 -29
  79. package/dist/wirings/http/http-runner.js +9 -34
  80. package/dist/wirings/http/http.types.d.ts +4 -12
  81. package/dist/wirings/mcp/mcp-runner.js +0 -2
  82. package/dist/wirings/mcp/mcp.types.d.ts +5 -11
  83. package/dist/wirings/oauth2/index.d.ts +0 -3
  84. package/dist/wirings/oauth2/index.js +1 -2
  85. package/dist/wirings/rpc/addon-runner.d.ts +28 -0
  86. package/dist/wirings/rpc/addon-runner.js +173 -0
  87. package/dist/wirings/rpc/index.d.ts +5 -1
  88. package/dist/wirings/rpc/index.js +3 -1
  89. package/dist/wirings/rpc/remote-addon-auth.d.ts +26 -0
  90. package/dist/wirings/rpc/remote-addon-auth.js +43 -0
  91. package/dist/wirings/rpc/rpc-runner.d.ts +19 -0
  92. package/dist/wirings/rpc/rpc-runner.js +109 -3
  93. package/dist/wirings/rpc/rpc-types.d.ts +15 -1
  94. package/dist/wirings/rpc/wire-addon.js +9 -0
  95. package/dist/wirings/rpc/wire-remote-addon.d.ts +47 -0
  96. package/dist/wirings/rpc/wire-remote-addon.js +19 -0
  97. package/dist/wirings/scope/index.d.ts +3 -0
  98. package/dist/wirings/scope/index.js +2 -0
  99. package/dist/wirings/scope/scope.types.d.ts +39 -0
  100. package/dist/wirings/scope/scope.types.js +1 -0
  101. package/dist/wirings/scope/validate-scope-definitions.d.ts +16 -0
  102. package/dist/wirings/scope/validate-scope-definitions.js +76 -0
  103. package/dist/wirings/scope/wire-scope.d.ts +33 -0
  104. package/dist/wirings/scope/wire-scope.js +32 -0
  105. package/dist/wirings/secret/secret.types.d.ts +14 -0
  106. package/dist/wirings/secret/validate-secret-definitions.js +2 -0
  107. package/dist/wirings/variable/validate-variable-definitions.js +2 -0
  108. package/dist/wirings/variable/variable.types.d.ts +14 -0
  109. package/dist/wirings/workflow/dsl/index.d.ts +1 -1
  110. package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +89 -7
  111. package/dist/wirings/workflow/graph/graph-node.d.ts +2 -0
  112. package/dist/wirings/workflow/graph/graph-runner.js +3 -0
  113. package/dist/wirings/workflow/graph/wire-workflow-graph.d.ts +6 -0
  114. package/dist/wirings/workflow/graph/wire-workflow-graph.js +1 -0
  115. package/dist/wirings/workflow/graph/workflow-graph.types.d.ts +5 -0
  116. package/dist/wirings/workflow/index.d.ts +2 -2
  117. package/dist/wirings/workflow/index.js +1 -1
  118. package/dist/wirings/workflow/pikku-workflow-service.d.ts +59 -1
  119. package/dist/wirings/workflow/pikku-workflow-service.js +187 -0
  120. package/dist/wirings/workflow/workflow.types.d.ts +1 -3
  121. package/package.json +6 -1
  122. package/run-tests.sh +1 -0
  123. package/src/dev/hot-reload.ts +13 -68
  124. package/src/dev/module-runner.test.ts +169 -0
  125. package/src/dev/module-runner.ts +103 -0
  126. package/src/dev/reload-meta.test.ts +31 -2
  127. package/src/dev/reload-meta.ts +26 -0
  128. package/src/errors/errors.ts +24 -0
  129. package/src/function/function-runner.test.ts +276 -142
  130. package/src/function/function-runner.ts +52 -145
  131. package/src/function/functions.types.ts +28 -38
  132. package/src/index.ts +8 -6
  133. package/src/middleware/index.ts +1 -5
  134. package/src/permissions.test.ts +160 -373
  135. package/src/permissions.ts +137 -279
  136. package/src/pikku-state.ts +0 -2
  137. package/src/scopes.test.ts +167 -0
  138. package/src/scopes.ts +69 -0
  139. package/src/services/ai-embedding-service.ts +31 -0
  140. package/src/services/credential-wire-service.ts +10 -4
  141. package/src/services/index.ts +20 -0
  142. package/src/services/meta-service.ts +68 -3
  143. package/src/services/queue-webhook-service.test.ts +408 -0
  144. package/src/services/queue-webhook-service.ts +182 -0
  145. package/src/services/scope-service.ts +75 -0
  146. package/src/services/typed-secret-service.test.ts +35 -0
  147. package/src/services/typed-secret-service.ts +39 -4
  148. package/src/services/webhook-service.ts +180 -0
  149. package/src/services/workflow-service.ts +7 -0
  150. package/src/testing/service-tests.ts +49 -0
  151. package/src/types/core.types.ts +42 -14
  152. package/src/types/state.types.ts +23 -3
  153. package/src/utils/hmac.ts +27 -0
  154. package/src/utils/safe-fetch.test.ts +373 -0
  155. package/src/utils/safe-fetch.ts +213 -0
  156. package/src/wirings/ai-agent/ai-agent-agui.test.ts +1127 -0
  157. package/src/wirings/ai-agent/ai-agent-agui.ts +386 -0
  158. package/src/wirings/ai-agent/ai-agent-authorization.test.ts +204 -0
  159. package/src/wirings/ai-agent/ai-agent-prepare.test.ts +483 -0
  160. package/src/wirings/ai-agent/ai-agent-prepare.ts +324 -13
  161. package/src/wirings/ai-agent/ai-agent-resume-authorization.test.ts +207 -0
  162. package/src/wirings/ai-agent/ai-agent-runner.test.ts +48 -1
  163. package/src/wirings/ai-agent/ai-agent-runner.ts +28 -0
  164. package/src/wirings/ai-agent/ai-agent-stream.test.ts +301 -10
  165. package/src/wirings/ai-agent/ai-agent-stream.ts +122 -16
  166. package/src/wirings/ai-agent/ai-agent.types.ts +57 -0
  167. package/src/wirings/ai-agent/index.ts +5 -0
  168. package/src/wirings/ai-agent/voice-input.test.ts +90 -0
  169. package/src/wirings/ai-agent/voice-input.ts +9 -40
  170. package/src/wirings/channel/channel-common.ts +0 -1
  171. package/src/wirings/channel/channel-handler.ts +1 -9
  172. package/src/wirings/channel/channel-middleware-runner.test.ts +28 -2
  173. package/src/wirings/channel/channel-middleware-runner.ts +39 -27
  174. package/src/wirings/channel/channel.types.ts +0 -6
  175. package/src/wirings/cli/cli-runner.ts +4 -2
  176. package/src/wirings/cli/cli.types.ts +0 -2
  177. package/src/wirings/cli/command-parser.test.ts +130 -0
  178. package/src/wirings/cli/command-parser.ts +80 -2
  179. package/src/wirings/credential/credential.types.ts +14 -0
  180. package/src/wirings/credential/validate-credential-definitions.ts +1 -0
  181. package/src/wirings/gateway/gateway-authorization.test.ts +444 -0
  182. package/src/wirings/gateway/gateway-runner.ts +114 -68
  183. package/src/wirings/gateway/gateway.types.ts +7 -9
  184. package/src/wirings/http/http-routes.ts +0 -3
  185. package/src/wirings/http/http-runner.test.ts +1 -11
  186. package/src/wirings/http/http-runner.ts +16 -39
  187. package/src/wirings/http/http.types.ts +7 -14
  188. package/src/wirings/mcp/mcp-runner.ts +0 -2
  189. package/src/wirings/mcp/mcp.types.ts +3 -11
  190. package/src/wirings/oauth2/index.ts +0 -3
  191. package/src/wirings/rpc/addon-runner.ts +233 -0
  192. package/src/wirings/rpc/index.ts +17 -1
  193. package/src/wirings/rpc/remote-addon-auth.ts +69 -0
  194. package/src/wirings/rpc/rpc-runner.test.ts +319 -16
  195. package/src/wirings/rpc/rpc-runner.ts +155 -3
  196. package/src/wirings/rpc/rpc-types.ts +16 -5
  197. package/src/wirings/rpc/wire-addon.test.ts +6 -3
  198. package/src/wirings/rpc/wire-addon.ts +9 -0
  199. package/src/wirings/rpc/wire-remote-addon.ts +57 -0
  200. package/src/wirings/scope/index.ts +14 -0
  201. package/src/wirings/scope/scope.test.ts +135 -0
  202. package/src/wirings/scope/scope.types.ts +44 -0
  203. package/src/wirings/scope/validate-scope-definitions.ts +110 -0
  204. package/src/wirings/scope/wire-scope.ts +34 -0
  205. package/src/wirings/secret/secret.types.ts +14 -0
  206. package/src/wirings/secret/validate-secret-definitions.ts +2 -0
  207. package/src/wirings/variable/validate-variable-definitions.ts +2 -0
  208. package/src/wirings/variable/variable.types.ts +14 -0
  209. package/src/wirings/workflow/dsl/index.ts +4 -0
  210. package/src/wirings/workflow/dsl/workflow-dsl.types.ts +97 -6
  211. package/src/wirings/workflow/graph/graph-node.ts +2 -0
  212. package/src/wirings/workflow/graph/graph-runner.test.ts +58 -0
  213. package/src/wirings/workflow/graph/graph-runner.ts +3 -0
  214. package/src/wirings/workflow/graph/wire-workflow-graph.ts +7 -0
  215. package/src/wirings/workflow/graph/workflow-graph.types.ts +5 -0
  216. package/src/wirings/workflow/index.ts +5 -0
  217. package/src/wirings/workflow/pikku-workflow-service.test.ts +341 -0
  218. package/src/wirings/workflow/pikku-workflow-service.ts +272 -0
  219. package/src/wirings/workflow/workflow-on-error.test.ts +154 -0
  220. package/src/wirings/workflow/workflow-step-session.test.ts +21 -5
  221. package/src/wirings/workflow/workflow.types.ts +4 -2
  222. package/tsconfig.tsbuildinfo +1 -1
  223. package/src/wirings/oauth2/oauth2-client.test.ts +0 -930
  224. package/src/wirings/oauth2/oauth2-client.ts +0 -331
  225. package/src/wirings/oauth2/oauth2-routes.ts +0 -234
@@ -48,9 +48,11 @@ import type { PikkuWire, SerializedError } from '../../types/core.types.js'
48
48
  import type { QueueService } from '../queue/queue.types.js'
49
49
  import { runScheduledTask } from '../scheduler/scheduler-runner.js'
50
50
  import type {
51
+ ApprovalOutcome,
51
52
  PikkuScenarioWire,
52
53
  StepState,
53
54
  StepStatus,
55
+ WorkflowApprovalOptions,
54
56
  WorkflowPlannedStep,
55
57
  WorkflowRun,
56
58
  WorkflowRunMirror,
@@ -203,6 +205,27 @@ addError(WorkflowRunCancelledError, {
203
205
  message: 'Workflow was cancelled.',
204
206
  })
205
207
 
208
+ /**
209
+ * A decision arrived for an approval gate that has already resolved. The gate
210
+ * caches its outcome as the step result and never re-reads run state, so the
211
+ * decision could not take effect — it is rejected rather than accepted and
212
+ * dropped.
213
+ */
214
+ export class WorkflowApprovalResolvedError extends PikkuError {
215
+ public payload: {
216
+ reason: string
217
+ outcome: ApprovalOutcome<unknown>['status']
218
+ }
219
+ constructor(reason: string, outcome: ApprovalOutcome<unknown>['status']) {
220
+ super(`Approval already ${outcome}: ${reason}`)
221
+ this.payload = { reason, outcome }
222
+ }
223
+ }
224
+ addError(WorkflowApprovalResolvedError, {
225
+ status: 409,
226
+ message: 'Approval has already been resolved.',
227
+ })
228
+
206
229
  export class WorkflowServiceNotInitialized extends Error {}
207
230
  export class WorkflowStepNameNotString extends Error {
208
231
  constructor(stepName: any) {
@@ -1802,6 +1825,30 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1802
1825
  }
1803
1826
  }
1804
1827
 
1828
+ /**
1829
+ * Run a failed step's compensation handler as a durable step of its own.
1830
+ *
1831
+ * Durable rather than a bare invoke so a replay does not compensate twice —
1832
+ * a handler is typically a refund or a rollback. `onError` is deliberately
1833
+ * not forwarded: a compensation handler cannot itself compensate.
1834
+ */
1835
+ private async runStepCompensation(
1836
+ runId: string,
1837
+ stepName: string,
1838
+ onErrorRpcName: string,
1839
+ rpcService: any,
1840
+ error: Error
1841
+ ): Promise<void> {
1842
+ await this.rpcStep(
1843
+ runId,
1844
+ `${stepName}:onError`,
1845
+ onErrorRpcName,
1846
+ { error: { message: error.message } },
1847
+ rpcService,
1848
+ { retries: 0 }
1849
+ )
1850
+ }
1851
+
1805
1852
  private async rpcStep(
1806
1853
  runId: string,
1807
1854
  logicalStepName: string,
@@ -1821,6 +1868,7 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1821
1868
  retries: stepOptions?.retries ?? DEFAULT_STEP_RETRIES,
1822
1869
  retryDelay: stepOptions?.retryDelay,
1823
1870
  actor: stepOptions?.actor,
1871
+ onError: stepOptions?.onError,
1824
1872
  }
1825
1873
  // Check if step already exists
1826
1874
  let stepState: StepState
@@ -1849,6 +1897,17 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1849
1897
  stepState.error?.message ||
1850
1898
  `Step '${stepName}' failed after exhausting all retries`
1851
1899
  )
1900
+ // Compensation, mirroring a graph node's onError: run the handler, then
1901
+ // still throw — the workflow fails either way.
1902
+ if (resolvedStepOptions.onError) {
1903
+ await this.runStepCompensation(
1904
+ runId,
1905
+ stepName,
1906
+ resolvedStepOptions.onError,
1907
+ rpcService,
1908
+ error
1909
+ )
1910
+ }
1852
1911
  // Preserve original error properties if available
1853
1912
  if (stepState.error) {
1854
1913
  Object.assign(error, stepState.error)
@@ -2148,6 +2207,214 @@ export abstract class PikkuWorkflowService implements WorkflowService {
2148
2207
  })
2149
2208
  }
2150
2209
 
2210
+ /**
2211
+ * Wake a run later by enqueuing a delayed orchestrator pass. Deliberately NOT
2212
+ * {@link scheduleSleep}: that resolves the step it is given, which for an
2213
+ * approval would resolve the gate itself. This only nudges the run to replay
2214
+ * and re-evaluate — the gate stays the sole judge of its own outcome.
2215
+ *
2216
+ * Best-effort by design. Expiry is decided from the recorded deadline on
2217
+ * replay, so losing this wake costs liveness (the run sits until something
2218
+ * else resumes it), never correctness.
2219
+ */
2220
+ private async scheduleRunWake(runId: string, delay: number): Promise<void> {
2221
+ try {
2222
+ const queueService = this.verifyQueueService()
2223
+ const run = await this.getRun(runId)
2224
+ if (!run?.workflow) return
2225
+ await queueService.add(
2226
+ this.getOrchestratorQueueName(run.workflow),
2227
+ { runId },
2228
+ { ...this.resolveStepJobOptions(), delay }
2229
+ )
2230
+ } catch (error) {
2231
+ this.logger?.warn(
2232
+ `Failed to schedule approval expiry wake for run ${runId}; expiry will still resolve on the next replay`,
2233
+ error
2234
+ )
2235
+ }
2236
+ }
2237
+
2238
+ /**
2239
+ * Durable step name for an approval gate. Namespaced separately from suspend
2240
+ * so the two can't collide, and derived from `reason` for the same reason
2241
+ * {@link getSuspendStepName} is: it must be stable across replays.
2242
+ */
2243
+ private getApprovalStepName(reason: string): string {
2244
+ return `__workflow_approval:${reason}`
2245
+ }
2246
+
2247
+ /**
2248
+ * Run-state key holding an approval gate's record. Hex-encoded because the
2249
+ * Mongo backend restricts state keys to `/^[a-zA-Z0-9_]+$/` and a `reason` is
2250
+ * arbitrary human text. One key per gate, so two gates resolving concurrently
2251
+ * can't clobber each other through a read-modify-write.
2252
+ */
2253
+ private approvalStateKey(stepName: string): string {
2254
+ let hex = ''
2255
+ for (const byte of new TextEncoder().encode(stepName)) {
2256
+ hex += byte.toString(16).padStart(2, '0')
2257
+ }
2258
+ return `__approval_${hex}`
2259
+ }
2260
+
2261
+ /**
2262
+ * Record a decision against an approval gate and wake the run. Called from
2263
+ * outside the workflow (an HTTP route, an RPC), so the schema value is NOT in
2264
+ * scope here — the payload is stored raw and validated on replay inside the
2265
+ * workflow body, which is the only place the schema exists. An invalid payload
2266
+ * therefore leaves the gate closed rather than failing the run.
2267
+ *
2268
+ * `reason` addresses the first reach of that gate. An approval reached more
2269
+ * than once under the same reason (e.g. in a loop) gets `#N`-suffixed step
2270
+ * rows that this cannot currently target.
2271
+ */
2272
+ public async approveStep(
2273
+ runId: string,
2274
+ reason: string,
2275
+ decision: unknown
2276
+ ): Promise<void> {
2277
+ const stepName = this.getApprovalStepName(reason)
2278
+ const stateKey = this.approvalStateKey(stepName)
2279
+
2280
+ // A resolved gate returns its cached step result and never re-reads state,
2281
+ // so a decision recorded now would be silently discarded — most obviously
2282
+ // when it loses the race with expiry. Reject instead, so the approver
2283
+ // learns their decision did not land.
2284
+ let resolved: StepState | undefined
2285
+ try {
2286
+ resolved = await this.getStepState(runId, stepName)
2287
+ } catch {
2288
+ // No step row yet: the run has not reached the gate. Recording a decision
2289
+ // ahead of it is legitimate — the gate picks it up on arrival.
2290
+ }
2291
+ if (resolved?.stepId && resolved.status === 'succeeded') {
2292
+ const outcome = resolved.result as ApprovalOutcome<unknown> | undefined
2293
+ throw new WorkflowApprovalResolvedError(
2294
+ reason,
2295
+ outcome?.status ?? 'decided'
2296
+ )
2297
+ }
2298
+
2299
+ const state = await this.getRunState(runId)
2300
+ const record = (state[stateKey] ?? {}) as Record<string, unknown>
2301
+ await this.updateRunState(runId, stateKey, {
2302
+ ...record,
2303
+ decision,
2304
+ decidedAt: new Date().toISOString(),
2305
+ error: undefined,
2306
+ })
2307
+ await this.resumeWorkflow(runId)
2308
+ }
2309
+
2310
+ private async approvalStep(
2311
+ runId: string,
2312
+ reason: string,
2313
+ options: WorkflowApprovalOptions
2314
+ ): Promise<ApprovalOutcome<unknown>> {
2315
+ const fromStepName = this.lastStepName(runId)
2316
+ const approvalStepName = this.nextStepKey(
2317
+ runId,
2318
+ this.getApprovalStepName(reason)
2319
+ )
2320
+ return await this.withStepLock(runId, approvalStepName, async () => {
2321
+ let stepState: StepState
2322
+ try {
2323
+ stepState = await this.getStepState(runId, approvalStepName)
2324
+ } catch {
2325
+ stepState = await this.insertStepState(
2326
+ runId,
2327
+ approvalStepName,
2328
+ 'pikkuWorkflowApproval',
2329
+ { reason, expiry: options.expiry },
2330
+ undefined,
2331
+ fromStepName
2332
+ )
2333
+ }
2334
+ if (!stepState.stepId) {
2335
+ stepState = await this.insertStepState(
2336
+ runId,
2337
+ approvalStepName,
2338
+ 'pikkuWorkflowApproval',
2339
+ { reason, expiry: options.expiry },
2340
+ undefined,
2341
+ fromStepName
2342
+ )
2343
+ }
2344
+
2345
+ // Unlike suspend, `succeeded` here means a decision (or expiry) was
2346
+ // actually resolved, and the step result IS the return channel.
2347
+ if (stepState.status === 'succeeded') {
2348
+ return stepState.result as ApprovalOutcome<unknown>
2349
+ }
2350
+
2351
+ const stateKey = this.approvalStateKey(approvalStepName)
2352
+ let record = ((await this.getRunState(runId))[stateKey] ?? {}) as {
2353
+ decision?: unknown
2354
+ decidedAt?: string
2355
+ expiresAt?: string
2356
+ error?: unknown
2357
+ }
2358
+
2359
+ if (stepState.status === 'pending') {
2360
+ await this.setStepRunning(stepState.stepId)
2361
+ // First reach: stamp the deadline and nudge the run awake when it
2362
+ // passes. The deadline is what's authoritative — see below.
2363
+ if (options.expiry !== undefined && !record.expiresAt) {
2364
+ const expiresAt = new Date(
2365
+ Date.now() + getDurationInMilliseconds(options.expiry)
2366
+ ).toISOString()
2367
+ record = { ...record, expiresAt }
2368
+ await this.updateRunState(runId, stateKey, record)
2369
+ await this.scheduleRunWake(
2370
+ runId,
2371
+ getDurationInMilliseconds(options.expiry)
2372
+ )
2373
+ }
2374
+ }
2375
+
2376
+ if (record.decision !== undefined) {
2377
+ const validation = await options.schema['~standard'].validate(
2378
+ record.decision
2379
+ )
2380
+ if (validation.issues) {
2381
+ // Drop the bad decision and re-close the gate, leaving the failure
2382
+ // legible to whoever tries next. Failing the run instead would let any
2383
+ // caller kill a workflow with a malformed payload.
2384
+ await this.updateRunState(runId, stateKey, {
2385
+ ...record,
2386
+ decision: undefined,
2387
+ decidedAt: undefined,
2388
+ error: validation.issues.map((issue) => ({
2389
+ message: issue.message,
2390
+ path: issue.path?.map((segment) =>
2391
+ typeof segment === 'object' ? segment.key : segment
2392
+ ),
2393
+ })),
2394
+ })
2395
+ throw new WorkflowSuspendedException(runId, reason)
2396
+ }
2397
+ const outcome: ApprovalOutcome<unknown> = {
2398
+ status: 'decided',
2399
+ data: validation.value,
2400
+ }
2401
+ await this.setStepResult(stepState.stepId, outcome)
2402
+ return outcome
2403
+ }
2404
+
2405
+ // Expiry is decided by comparing against the recorded deadline rather than
2406
+ // by the timer having fired, so a duplicate, late, or dropped timer all
2407
+ // produce the same answer.
2408
+ if (record.expiresAt && Date.now() >= Date.parse(record.expiresAt)) {
2409
+ const outcome: ApprovalOutcome<unknown> = { status: 'expired' }
2410
+ await this.setStepResult(stepState.stepId, outcome)
2411
+ return outcome
2412
+ }
2413
+
2414
+ throw new WorkflowSuspendedException(runId, reason)
2415
+ })
2416
+ }
2417
+
2151
2418
  public createWorkflowWire(
2152
2419
  name: string,
2153
2420
  runId: string,
@@ -2345,6 +2612,11 @@ export abstract class PikkuWorkflowService implements WorkflowService {
2345
2612
  await this.suspendStep(runId, reason)
2346
2613
  },
2347
2614
 
2615
+ approval: (async (reason: string, options: WorkflowApprovalOptions) => {
2616
+ this.verifyStepName(reason)
2617
+ return await this.approvalStep(runId, reason, options)
2618
+ }) as PikkuScenarioWire['approval'],
2619
+
2348
2620
  runScheduledTask: async (taskName: string) => {
2349
2621
  await runScheduledTask({ name: taskName })
2350
2622
  },
@@ -0,0 +1,154 @@
1
+ import { describe, test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+
4
+ import { InMemoryWorkflowService } from '../../services/in-memory-workflow-service.js'
5
+
6
+ /**
7
+ * Drive the real failure branch: a step that is already terminally failed must
8
+ * invoke its onError handler and still throw. Only the nested compensation
9
+ * call is intercepted, so the branch under test is the production one.
10
+ */
11
+ class TestWorkflowService extends InMemoryWorkflowService {
12
+ public compensations: Array<{ rpcName: string; data: any }> = []
13
+ public dispatches = 0
14
+ private pretendDispatchSucceeds = false
15
+
16
+ public alwaysDispatch() {
17
+ this.pretendDispatchSucceeds = true
18
+ }
19
+
20
+ protected override async dispatchStep(...args: any[]): Promise<boolean> {
21
+ this.dispatches++
22
+ if (this.pretendDispatchSucceeds) {
23
+ return true
24
+ }
25
+ return (super.dispatchStep as any)(...args)
26
+ }
27
+
28
+ public async callFailedStep(
29
+ runId: string,
30
+ stepName: string,
31
+ onError?: string
32
+ ) {
33
+ const self = this as any
34
+ const realRpcStep = self.rpcStep.bind(self)
35
+ let depth = 0
36
+ self.rpcStep = async (...args: any[]) => {
37
+ if (depth++ > 0) {
38
+ this.compensations.push({ rpcName: args[2], data: args[3] })
39
+ return { compensated: true }
40
+ }
41
+ return realRpcStep(...args)
42
+ }
43
+ return self.rpcStep(runId, stepName, 'chargeCard', {}, {}, { onError })
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Seed a step in a non-terminal state, as a replay would find one that is
49
+ * waiting on the queue rather than one that has failed.
50
+ */
51
+ async function seedPendingStep(ws: TestWorkflowService, stepName: string) {
52
+ const runId = await ws.createRun('wf', {}, true, 'hash', {
53
+ type: 'inline',
54
+ } as any)
55
+ const step = await ws.insertStepState(runId, stepName, 'chargeCard', {})
56
+ return { runId, step }
57
+ }
58
+
59
+ async function seedFailedStep(ws: TestWorkflowService, stepName: string) {
60
+ const runId = await ws.createRun('wf', {}, true, 'hash', {
61
+ type: 'inline',
62
+ } as any)
63
+ const step = await ws.insertStepState(runId, stepName, 'chargeCard', {})
64
+ await (ws as any).updateStepStateImpl?.(runId, stepName, {
65
+ status: 'failed',
66
+ error: { message: 'card declined' },
67
+ })
68
+ const raw = (ws as any).steps ?? (ws as any).stepStates
69
+ if (raw?.get) {
70
+ const key = [...raw.keys()].find((k: string) => k.includes(stepName))
71
+ if (key) {
72
+ raw.set(key, {
73
+ ...raw.get(key),
74
+ status: 'failed',
75
+ error: { message: 'card declined' },
76
+ })
77
+ }
78
+ }
79
+ return { runId, step }
80
+ }
81
+
82
+ describe('workflow onError — compensation on terminal step failure', () => {
83
+ test('a failed step runs its handler and still throws the original error', async () => {
84
+ const ws = new TestWorkflowService()
85
+ const { runId } = await seedFailedStep(ws, 'Charge')
86
+
87
+ await assert.rejects(
88
+ () => ws.callFailedStep(runId, 'Charge', 'refundOrder'),
89
+ /card declined/,
90
+ 'compensation must not swallow the failure — the workflow still fails'
91
+ )
92
+
93
+ assert.equal(
94
+ ws.compensations.length,
95
+ 1,
96
+ 'the onError handler must have been invoked'
97
+ )
98
+ assert.equal(ws.compensations[0].rpcName, 'refundOrder')
99
+ assert.deepEqual(
100
+ ws.compensations[0].data,
101
+ { error: { message: 'card declined' } },
102
+ 'the handler receives the failure reason, as a graph onError node does'
103
+ )
104
+ })
105
+
106
+ test('a step still sitting on the queue pauses without compensating', async () => {
107
+ const ws = new TestWorkflowService()
108
+ const { runId, step } = await seedPendingStep(ws, 'Charge')
109
+ await ws.setStepScheduled(step.stepId)
110
+
111
+ await assert.rejects(
112
+ () => ws.callFailedStep(runId, 'Charge', 'refundOrder'),
113
+ (error: Error) => error.name === 'WorkflowAsyncException',
114
+ 'a scheduled step must pause the workflow, not fail it'
115
+ )
116
+
117
+ assert.equal(
118
+ ws.compensations.length,
119
+ 0,
120
+ 'compensating a step that has not run yet would refund a charge that was never made'
121
+ )
122
+ })
123
+
124
+ test('the pause thrown right after dispatch does not compensate', async () => {
125
+ const ws = new TestWorkflowService()
126
+ ws.alwaysDispatch()
127
+ const { runId } = await seedPendingStep(ws, 'Charge')
128
+
129
+ await assert.rejects(
130
+ () => ws.callFailedStep(runId, 'Charge', 'refundOrder'),
131
+ (error: Error) => error.name === 'WorkflowAsyncException',
132
+ 'a freshly dispatched step pauses the workflow'
133
+ )
134
+
135
+ assert.equal(ws.dispatches, 1, 'the step should have been dispatched once')
136
+ assert.equal(
137
+ ws.compensations.length,
138
+ 0,
139
+ 'queued is not failed — the handler must not run'
140
+ )
141
+ })
142
+
143
+ test('a failed step with no onError simply throws', async () => {
144
+ const ws = new TestWorkflowService()
145
+ const { runId } = await seedFailedStep(ws, 'Charge')
146
+
147
+ await assert.rejects(() => ws.callFailedStep(runId, 'Charge', undefined))
148
+ assert.equal(
149
+ ws.compensations.length,
150
+ 0,
151
+ 'no handler configured means nothing to compensate'
152
+ )
153
+ })
154
+ })
@@ -31,7 +31,9 @@ function capturingRpcService(): {
31
31
 
32
32
  describe('queued workflow steps carry the run wire pikkuUserId', () => {
33
33
  test('invokeStepRpc merges run.wire.pikkuUserId into the step wire override', async () => {
34
- pikkuState(null, 'package', 'singletonServices', { logger: silentLogger } as any)
34
+ pikkuState(null, 'package', 'singletonServices', {
35
+ logger: silentLogger,
36
+ } as any)
35
37
 
36
38
  const ws = new InMemoryWorkflowService()
37
39
  const runId = await ws.createRun('flow', {}, false, 'hash', {
@@ -56,19 +58,33 @@ describe('queued workflow steps carry the run wire pikkuUserId', () => {
56
58
  'user-abc',
57
59
  'the step wire override carries the run wire pikkuUserId so authed steps rehydrate'
58
60
  )
59
- assert.ok(rpc.wire().workflowStep, 'workflowStep provenance is still present')
61
+ assert.ok(
62
+ rpc.wire().workflowStep,
63
+ 'workflowStep provenance is still present'
64
+ )
60
65
  })
61
66
 
62
67
  test('no pikkuUserId key is injected when the run wire has none', async () => {
63
- pikkuState(null, 'package', 'singletonServices', { logger: silentLogger } as any)
68
+ pikkuState(null, 'package', 'singletonServices', {
69
+ logger: silentLogger,
70
+ } as any)
64
71
 
65
72
  const ws = new InMemoryWorkflowService()
66
- const runId = await ws.createRun('flow', {}, false, 'hash', { type: 'test' })
73
+ const runId = await ws.createRun('flow', {}, false, 'hash', {
74
+ type: 'test',
75
+ })
67
76
  await ws.insertStepState(runId, 'noauth', 'someFn', {})
68
77
  const stepState = await ws.getStepState(runId, 'noauth')
69
78
 
70
79
  const rpc = capturingRpcService()
71
- await (ws as any).invokeStepRpc(runId, 'noauth', stepState, 'someFn', {}, rpc.service)
80
+ await (ws as any).invokeStepRpc(
81
+ runId,
82
+ 'noauth',
83
+ stepState,
84
+ 'someFn',
85
+ {},
86
+ rpc.service
87
+ )
72
88
 
73
89
  assert.ok(
74
90
  !('pikkuUserId' in rpc.wire()),
@@ -14,6 +14,9 @@ export type {
14
14
  WorkflowWireDoInline,
15
15
  WorkflowWireSleep,
16
16
  WorkflowWireSuspend,
17
+ WorkflowWireApproval,
18
+ WorkflowApprovalOptions,
19
+ ApprovalOutcome,
17
20
  InputSource,
18
21
  OutputBinding,
19
22
  RpcStepMeta,
@@ -28,6 +31,7 @@ export type {
28
31
  SleepStepMeta,
29
32
  CancelStepMeta,
30
33
  SuspendStepMeta,
34
+ ApprovalStepMeta,
31
35
  SetStepMeta,
32
36
  SwitchCaseMeta,
33
37
  SwitchStepMeta,
@@ -282,8 +286,6 @@ export type CoreWorkflow<
282
286
  func: PikkuFunctionConfig
283
287
  /** Middleware chain for this workflow */
284
288
  middleware?: PikkuFunctionConfig['middleware']
285
- /** Permission requirements */
286
- permissions?: PikkuFunctionConfig['permissions']
287
289
  /** Tags for organization and filtering */
288
290
  tags?: string[]
289
291
  }