@pikku/core 0.12.64 → 0.12.67

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 (74) hide show
  1. package/CHANGELOG.md +311 -0
  2. package/dist/index.d.ts +2 -2
  3. package/dist/index.js +1 -1
  4. package/dist/permissions.d.ts +12 -4
  5. package/dist/permissions.js +11 -32
  6. package/dist/scopes.d.ts +14 -0
  7. package/dist/scopes.js +39 -8
  8. package/dist/testing/service-tests.js +37 -0
  9. package/dist/types/core.types.d.ts +22 -0
  10. package/dist/wirings/ai-agent/ai-agent-prepare.d.ts +64 -0
  11. package/dist/wirings/ai-agent/ai-agent-prepare.js +103 -5
  12. package/dist/wirings/ai-agent/ai-agent-runner.js +5 -1
  13. package/dist/wirings/ai-agent/ai-agent-stream.js +28 -7
  14. package/dist/wirings/ai-agent/ai-agent.types.d.ts +29 -1
  15. package/dist/wirings/ai-agent/index.d.ts +1 -1
  16. package/dist/wirings/ai-agent/index.js +1 -1
  17. package/dist/wirings/ai-agent/voice-input.js +3 -3
  18. package/dist/wirings/cli/cli-runner.js +3 -0
  19. package/dist/wirings/cli/command-parser.d.ts +2 -0
  20. package/dist/wirings/cli/command-parser.js +59 -2
  21. package/dist/wirings/credential/credential.types.d.ts +14 -0
  22. package/dist/wirings/credential/validate-credential-definitions.js +1 -0
  23. package/dist/wirings/gateway/gateway-runner.js +100 -50
  24. package/dist/wirings/gateway/gateway.types.d.ts +8 -5
  25. package/dist/wirings/http/http.types.d.ts +3 -3
  26. package/dist/wirings/scope/validate-scope-definitions.d.ts +8 -0
  27. package/dist/wirings/scope/validate-scope-definitions.js +16 -1
  28. package/dist/wirings/secret/secret.types.d.ts +14 -0
  29. package/dist/wirings/secret/validate-secret-definitions.js +2 -0
  30. package/dist/wirings/variable/validate-variable-definitions.js +2 -0
  31. package/dist/wirings/variable/variable.types.d.ts +14 -0
  32. package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +36 -6
  33. package/dist/wirings/workflow/pikku-workflow-service.d.ts +8 -0
  34. package/dist/wirings/workflow/pikku-workflow-service.js +16 -0
  35. package/dist/wirings/workflow/workflow.types.d.ts +0 -2
  36. package/package.json +2 -1
  37. package/src/index.ts +2 -1
  38. package/src/permissions.test.ts +14 -8
  39. package/src/permissions.ts +14 -36
  40. package/src/scopes.test.ts +37 -1
  41. package/src/scopes.ts +48 -9
  42. package/src/testing/service-tests.ts +49 -0
  43. package/src/types/core.types.ts +23 -0
  44. package/src/wirings/ai-agent/ai-agent-authorization.test.ts +204 -0
  45. package/src/wirings/ai-agent/ai-agent-prepare.test.ts +175 -0
  46. package/src/wirings/ai-agent/ai-agent-prepare.ts +132 -5
  47. package/src/wirings/ai-agent/ai-agent-resume-authorization.test.ts +207 -0
  48. package/src/wirings/ai-agent/ai-agent-runner.ts +7 -0
  49. package/src/wirings/ai-agent/ai-agent-stream.test.ts +103 -0
  50. package/src/wirings/ai-agent/ai-agent-stream.ts +38 -6
  51. package/src/wirings/ai-agent/ai-agent.types.ts +29 -0
  52. package/src/wirings/ai-agent/index.ts +4 -0
  53. package/src/wirings/ai-agent/voice-input.test.ts +90 -0
  54. package/src/wirings/ai-agent/voice-input.ts +8 -10
  55. package/src/wirings/cli/cli-runner.ts +4 -0
  56. package/src/wirings/cli/command-parser.test.ts +130 -0
  57. package/src/wirings/cli/command-parser.ts +80 -2
  58. package/src/wirings/credential/credential.types.ts +14 -0
  59. package/src/wirings/credential/validate-credential-definitions.ts +1 -0
  60. package/src/wirings/gateway/gateway-authorization.test.ts +444 -0
  61. package/src/wirings/gateway/gateway-runner.ts +114 -68
  62. package/src/wirings/gateway/gateway.types.ts +7 -9
  63. package/src/wirings/http/http.types.ts +6 -4
  64. package/src/wirings/scope/scope.test.ts +25 -0
  65. package/src/wirings/scope/validate-scope-definitions.ts +16 -1
  66. package/src/wirings/secret/secret.types.ts +14 -0
  67. package/src/wirings/secret/validate-secret-definitions.ts +2 -0
  68. package/src/wirings/variable/validate-variable-definitions.ts +2 -0
  69. package/src/wirings/variable/variable.types.ts +14 -0
  70. package/src/wirings/workflow/dsl/workflow-dsl.types.ts +36 -6
  71. package/src/wirings/workflow/pikku-workflow-service.ts +36 -0
  72. package/src/wirings/workflow/workflow-on-error.test.ts +154 -0
  73. package/src/wirings/workflow/workflow.types.ts +0 -2
  74. package/tsconfig.tsbuildinfo +1 -1
@@ -4,11 +4,7 @@ import type {
4
4
  CorePikkuMiddlewareGroup,
5
5
  CoreSingletonServices,
6
6
  } from '../../types/core.types.js'
7
- import type {
8
- CorePikkuFunctionConfig,
9
- CorePermissionGroup,
10
- CorePikkuPermission,
11
- } from '../../function/functions.types.js'
7
+ import type { CorePikkuFunctionConfig } from '../../function/functions.types.js'
12
8
  import type { PikkuHTTPRequest } from '../http/http.types.js'
13
9
 
14
10
  /**
@@ -124,7 +120,6 @@ export type GatewayTransportType = 'webhook' | 'websocket' | 'listener'
124
120
  */
125
121
  export type CoreGateway<
126
122
  PikkuFunctionConfig = CorePikkuFunctionConfig<any, any>,
127
- PikkuPermission extends CorePikkuPermission = CorePikkuPermission,
128
123
  PikkuMiddleware extends CorePikkuMiddleware = CorePikkuMiddleware,
129
124
  > = Partial<
130
125
  Pick<CommonWireMeta, 'title' | 'summary' | 'description' | 'errors'>
@@ -143,11 +138,14 @@ export type CoreGateway<
143
138
  func: PikkuFunctionConfig
144
139
  /** Optional middleware chain (e.g., auth) */
145
140
  middleware?: CorePikkuMiddlewareGroup<any, any>
146
- /** Optional permissions */
147
- permissions?: CorePermissionGroup | PikkuPermission[]
148
141
  /** Optional tags for categorization */
149
142
  tags?: string[]
150
- /** Whether authentication is required (default: true) */
143
+ /**
144
+ * Whether the handler requires a session. Left unset, the handler's own
145
+ * `auth` governs, and a gateway handler is sessionless by default — inbound
146
+ * gateway traffic is platform-authenticated by the adapter, not
147
+ * session-bearing. Set `true` to require a session for every message.
148
+ */
151
149
  auth?: boolean
152
150
  }
153
151
 
@@ -126,8 +126,10 @@ export type CoreHTTPFunctionWiring<
126
126
  > = CorePikkuFunctionSessionless<In, Out>,
127
127
  PikkuPermission extends CorePikkuPermission<In, any, any> =
128
128
  CorePikkuPermission<In, any, any>,
129
- PikkuMiddleware extends CorePikkuMiddleware<any, any> =
130
- CorePikkuMiddleware<any>,
129
+ PikkuMiddleware extends CorePikkuMiddleware<any, any> = CorePikkuMiddleware<
130
+ any,
131
+ any
132
+ >,
131
133
  > =
132
134
  | (CoreHTTPFunction & {
133
135
  route: R
@@ -287,7 +289,7 @@ export type HTTPRouteConfig<
287
289
  | CorePikkuFunction<any, any, any, any, any>
288
290
  | CorePikkuFunctionSessionless<any, any, any, any, any>,
289
291
  PikkuPermission extends CorePikkuPermission<any, any, any> =
290
- CorePikkuPermission<any>,
292
+ CorePikkuPermission<any, any, any>,
291
293
  PikkuMiddleware extends CorePikkuMiddleware<any, any> = CorePikkuMiddleware<
292
294
  any,
293
295
  any
@@ -313,7 +315,7 @@ export type HTTPRouteConfig<
313
315
  */
314
316
  export type HTTPRoutesGroupConfig<
315
317
  PikkuPermission extends CorePikkuPermission<any, any, any> =
316
- CorePikkuPermission<any>,
318
+ CorePikkuPermission<any, any, any>,
317
319
  PikkuMiddleware extends CorePikkuMiddleware<any, any> = CorePikkuMiddleware<
318
320
  any,
319
321
  any
@@ -62,6 +62,31 @@ describe('flattenScopeDefinitions', () => {
62
62
  ['admin', 'admin:users', 'billing']
63
63
  )
64
64
  })
65
+
66
+ // An addon and its host app may both contribute the same root. Codegen writes
67
+ // these ids into an object literal, where a repeat is a TypeScript error.
68
+ test('a root declared twice is flattened once', () => {
69
+ const tree = { users: { scopes: { ban: {} } } }
70
+ const definitions: ScopeDefinitions = [
71
+ { name: 'admin', description: 'Administration', scopes: tree },
72
+ { name: 'admin', description: 'Administration', scopes: tree },
73
+ ]
74
+
75
+ assert.deepEqual(
76
+ flattenScopeDefinitions(definitions).map((s) => s.id),
77
+ ['admin', 'admin:users', 'admin:users:ban']
78
+ )
79
+ })
80
+
81
+ test('the first description of a repeated scope wins', () => {
82
+ assert.deepEqual(
83
+ flattenScopeDefinitions([
84
+ { name: 'admin', description: 'Administration' },
85
+ { name: 'admin', description: 'Something else' },
86
+ ]),
87
+ [{ id: 'admin', description: 'Administration' }]
88
+ )
89
+ })
65
90
  })
66
91
 
67
92
  describe('validateAndBuildScopeDefinitionsMeta', () => {
@@ -52,6 +52,14 @@ const flattenNodes = (
52
52
  * Flattens declared scope trees into the full list of grantable scope ids,
53
53
  * depth-first. Every node is emitted, including intermediate ones.
54
54
  *
55
+ * Ids are unique. A root may legitimately be declared more than once — an addon
56
+ * and its host app both contributing the same `admin` tree, say — and
57
+ * {@link validateAndBuildScopeDefinitionsMeta} already guarantees those
58
+ * declarations are identical, so the second one is redundant rather than
59
+ * conflicting. Collapsing it here keeps every consumer honest: codegen emits an
60
+ * object literal keyed by id (duplicates are a TypeScript error), and a
61
+ * ScopeService syncs one row per scope instead of re-writing the same one.
62
+ *
55
63
  * Used by codegen to build the `ScopeId` union, and by a ScopeService to sync
56
64
  * the declared set into its store.
57
65
  */
@@ -63,7 +71,14 @@ export const flattenScopeDefinitions = (
63
71
  out.push({ id: def.name, description: def.description })
64
72
  flattenNodes(def.scopes, def.name, out)
65
73
  }
66
- return out
74
+ const seen = new Set<string>()
75
+ return out.filter((scope) => {
76
+ if (seen.has(scope.id)) {
77
+ return false
78
+ }
79
+ seen.add(scope.id)
80
+ return true
81
+ })
67
82
  }
68
83
 
69
84
  /**
@@ -4,6 +4,13 @@ export type CoreSecret<T = unknown> = {
4
4
  description?: string
5
5
  secretId: string
6
6
  schema: T
7
+ /**
8
+ * Link to documentation explaining how to obtain this value — a provider's
9
+ * API-key page, a setup guide, an internal runbook. Surfaced by consoles and
10
+ * deploy UIs so a user facing a missing value has somewhere to go instead of
11
+ * an opaque identifier.
12
+ */
13
+ docsUrl?: string
7
14
  /**
8
15
  * Optional rotation cadence for this secret, e.g. '1d', '30day', '1w'.
9
16
  * Stored in the generated secrets metadata so consumers can tell when a
@@ -27,6 +34,13 @@ export type SecretDefinitionMeta = {
27
34
  description?: string
28
35
  secretId: string
29
36
  schema?: Record<string, unknown> | string
37
+ /**
38
+ * Link to documentation explaining how to obtain this value — a provider's
39
+ * API-key page, a setup guide, an internal runbook. Surfaced by consoles and
40
+ * deploy UIs so a user facing a missing value has somewhere to go instead of
41
+ * an opaque identifier.
42
+ */
43
+ docsUrl?: string
30
44
  oauth2?: OAuth2CredentialConfig
31
45
  rotationPeriod?: string
32
46
  sourceFile?: string
@@ -58,6 +58,7 @@ export function validateAndBuildSecretDefinitionsMeta(
58
58
  schema: def.schema,
59
59
  oauth2: def.oauth2,
60
60
  rotationPeriod: def.rotationPeriod,
61
+ docsUrl: def.docsUrl,
61
62
  sourceFile: def.sourceFile,
62
63
  }
63
64
  }
@@ -75,6 +76,7 @@ export function validateAndBuildSecretDefinitionsMeta(
75
76
  schema: def.schema,
76
77
  oauth2: def.oauth2,
77
78
  rotationPeriod: def.rotationPeriod,
79
+ docsUrl: def.docsUrl,
78
80
  sourceFile: def.sourceFile,
79
81
  }
80
82
  }
@@ -45,6 +45,7 @@ export function validateAndBuildVariableDefinitionsMeta(
45
45
  description: def.description,
46
46
  variableId: def.variableId,
47
47
  schema: def.schema,
48
+ docsUrl: def.docsUrl,
48
49
  sourceFile: def.sourceFile,
49
50
  }
50
51
  }
@@ -60,6 +61,7 @@ export function validateAndBuildVariableDefinitionsMeta(
60
61
  description: def.description,
61
62
  variableId: def.variableId,
62
63
  schema: def.schema,
64
+ docsUrl: def.docsUrl,
63
65
  sourceFile: def.sourceFile,
64
66
  }
65
67
  }
@@ -4,6 +4,13 @@ export type CoreVariable<T = unknown> = {
4
4
  description?: string
5
5
  variableId: string
6
6
  schema: T
7
+ /**
8
+ * Link to documentation explaining how to obtain this value — a provider's
9
+ * API-key page, a setup guide, an internal runbook. Surfaced by consoles and
10
+ * deploy UIs so a user facing a missing value has somewhere to go instead of
11
+ * an opaque identifier.
12
+ */
13
+ docsUrl?: string
7
14
  }
8
15
 
9
16
  export type VariableDefinitionMeta = {
@@ -12,6 +19,13 @@ export type VariableDefinitionMeta = {
12
19
  description?: string
13
20
  variableId: string
14
21
  schema?: Record<string, unknown> | string
22
+ /**
23
+ * Link to documentation explaining how to obtain this value — a provider's
24
+ * API-key page, a setup guide, an internal runbook. Surfaced by consoles and
25
+ * deploy UIs so a user facing a missing value has somewhere to go instead of
26
+ * an opaque identifier.
27
+ */
28
+ docsUrl?: string
15
29
  sourceFile?: string
16
30
  }
17
31
 
@@ -18,6 +18,13 @@ export interface WorkflowStepOptions {
18
18
  retries?: number
19
19
  /** Delay between retry attempts (e.g., '1s', '2s', '2min') */
20
20
  retryDelay?: string | number
21
+ /**
22
+ * RPC to invoke for compensation when this step fails after exhausting its
23
+ * retries. Mirrors a graph node's `onError`: the handler receives
24
+ * `{ error: { message } }` and the original error is still thrown, so the
25
+ * workflow fails — this is compensation, not recovery.
26
+ */
27
+ onError?: string
21
28
  /**
22
29
  * Run this step as an actor (scenarios). The RPC is sent through the
23
30
  * actor's authenticated client over the REAL transport — never dispatched
@@ -224,16 +231,20 @@ export interface ParallelGroupStepMeta {
224
231
  */
225
232
  export interface FanoutStepMeta {
226
233
  type: 'fanout'
227
- /** Step name for this fanout */
228
- stepName: string
234
+ /**
235
+ * Step name for this fanout. Optional: a fanout is not itself a cached step,
236
+ * and node ids are step names — borrowing a body step's name would give the
237
+ * loop and that step the same id, collapsing one onto the other.
238
+ */
239
+ stepName?: string
229
240
  /** Source array variable name */
230
241
  sourceVar: string
231
242
  /** Iterator variable name */
232
243
  itemVar: string
233
244
  /** Execution mode */
234
245
  mode: 'parallel' | 'sequential'
235
- /** Child step to execute per iteration */
236
- child: RpcStepMeta
246
+ /** Steps to execute inline per iteration, in order */
247
+ body: Array<RpcStepMeta | SleepStepMeta | SuspendStepMeta>
237
248
  /** Time between iterations (sequential mode only) */
238
249
  timeBetween?: string
239
250
  }
@@ -245,6 +256,12 @@ export interface ReturnStepMeta {
245
256
  type: 'return'
246
257
  /** Output bindings */
247
258
  outputs: Record<string, OutputBinding>
259
+ /**
260
+ * Variables spread into the returned object (`return { ...r }`), or the sole
261
+ * returned variable (`return r`). Their fields are not enumerable statically,
262
+ * so they are recorded by name rather than expanded into `outputs`.
263
+ */
264
+ spread?: string[]
248
265
  }
249
266
 
250
267
  /**
@@ -271,6 +288,13 @@ export interface SleepStepMeta {
271
288
  stepName: string
272
289
  /** Sleep duration */
273
290
  duration: string | number
291
+ /**
292
+ * Source text of a duration only known at runtime (e.g. a loop variable).
293
+ * The closure evaluates it, so it is legal DSL; it is kept separate from
294
+ * `duration` so regenerated code emits it raw rather than as a string
295
+ * literal, exactly as `expression` does on a set step.
296
+ */
297
+ expression?: string
274
298
  }
275
299
 
276
300
  /**
@@ -291,8 +315,14 @@ export interface SetStepMeta {
291
315
  type: 'set'
292
316
  /** Variable name to set (must be in context) */
293
317
  variable: string
294
- /** Value to assign (literal or expression) */
295
- value: unknown
318
+ /** Literal value to assign. Mutually exclusive with `expression`. */
319
+ value?: unknown
320
+ /**
321
+ * Source text of a non-literal assignment (e.g. `count + 1`). Kept separate
322
+ * from `value` so regenerated code can emit it raw — a string `value` is a
323
+ * string literal, an `expression` is code.
324
+ */
325
+ expression?: string
296
326
  }
297
327
 
298
328
  /**
@@ -1825,6 +1825,30 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1825
1825
  }
1826
1826
  }
1827
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
+
1828
1852
  private async rpcStep(
1829
1853
  runId: string,
1830
1854
  logicalStepName: string,
@@ -1844,6 +1868,7 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1844
1868
  retries: stepOptions?.retries ?? DEFAULT_STEP_RETRIES,
1845
1869
  retryDelay: stepOptions?.retryDelay,
1846
1870
  actor: stepOptions?.actor,
1871
+ onError: stepOptions?.onError,
1847
1872
  }
1848
1873
  // Check if step already exists
1849
1874
  let stepState: StepState
@@ -1872,6 +1897,17 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1872
1897
  stepState.error?.message ||
1873
1898
  `Step '${stepName}' failed after exhausting all retries`
1874
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
+ }
1875
1911
  // Preserve original error properties if available
1876
1912
  if (stepState.error) {
1877
1913
  Object.assign(error, stepState.error)
@@ -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
+ })
@@ -286,8 +286,6 @@ export type CoreWorkflow<
286
286
  func: PikkuFunctionConfig
287
287
  /** Middleware chain for this workflow */
288
288
  middleware?: PikkuFunctionConfig['middleware']
289
- /** Permission requirements */
290
- permissions?: PikkuFunctionConfig['permissions']
291
289
  /** Tags for organization and filtering */
292
290
  tags?: string[]
293
291
  }