@pikku/skills 0.12.46 → 0.12.47

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikku/skills",
3
- "version": "0.12.46",
3
+ "version": "0.12.47",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/pikkujs/pikku.git",
@@ -113,8 +113,8 @@ subscribe to its events as `<source>:<event>`:
113
113
  logged and dropped; so is one no `wireTrigger` listens for. Both still get a
114
114
  `200`, so the provider does not retry forever.
115
115
  - `receive(services, { body, headers, method, url, query })` gets the **raw
116
- bytes** — verify the signature over those — and returns `{ events: [{ name,
117
- id?, data }] }`, or `{ respond: { status, body } }` for a handshake. Throwing
116
+ bytes** and only parses them: it returns `{ events: [{ name, id?, data }] }`,
117
+ or `{ respond: { status, body } }` for a handshake. Throwing
118
118
  rejects the request with the error's status (`UnauthorizedError` → 401).
119
119
  Omitted, the JSON body becomes one event dispatched to a trigger named just
120
120
  `<source>`.
@@ -129,12 +129,31 @@ id?, data }] }`, or `{ respond: { status, body } }` for a handshake. Throwing
129
129
  even on queues that ignore job ids, and records each attempt and its last
130
130
  error. Its `webhookReceipt` table comes from `pikku db generate`; `pikku dev`
131
131
  and `pikku serve` use it when a Kysely database is configured.
132
- - `receive` sees singleton services without `secrets`. Declare the signing
133
- secret with `defineCredential({ type: 'singleton', ... })` and hold it as
134
- `WebhookSigningSecret.fromCredential(provider, credentialService, name)`
135
- from `@pikku/core/hmac`; `receive` calls `await signingSecret.load()` and
136
- checks against what it returns. A handshake that hands over the secret
137
- (Asana) stores it with `credentialService.set`.
132
+ - Signature checks belong in `verify`, never in `receive`. pikku runs it on
133
+ every request against the secret in the credential
134
+ `<name>WebhookSecret` (camelCased: `microsoft-outlook` →
135
+ `microsoftOutlookWebhookSecret`; `credential` overrides it). Declaring
136
+ `verify` declares that credential as a singleton string, so there is no
137
+ `defineCredential` and no service holding the secret; `credentialDescription`
138
+ tells whoever sets it where to find it. Pick the declared form that matches
139
+ the provider:
140
+ - `{ hmac: { header, prefix?, algorithm, encoding, secretEncoding? } }`:
141
+ a signature over the raw body in one header (GitHub, Shopify, Linear).
142
+ - `{ token: { header, prefix? } }`: the provider echoes the shared secret
143
+ (GitLab, Telegram).
144
+ - `{ publicKey: { header, algorithm?, dsaEncoding? } }`: signed with the
145
+ provider's private key; the stored secret is its PEM public key (Wise).
146
+ - Anything else (a timestamp in the signed payload, a signature in the body
147
+ or query, a URL in the signed string) is a function
148
+ `(request, secret, services) => boolean`, built from `hmacDigest`,
149
+ `verifyHmacSignature`, `verifyPublicKeySignature` and
150
+ `timingSafeStringEqual` in `@pikku/core/hmac`.
151
+
152
+ A request with a body that fails is refused with a 401. A bodiless request
153
+ that fails (a HEAD probe, a validation token in the query) still reaches
154
+ `receive` so it can answer the handshake, but any events it returns are
155
+ refused. A handshake that hands over the secret (Asana) stores it with
156
+ `credentialService.set` under the same credential name.
138
157
 
139
158
  `check`, `setup` and `teardown` register the route with the provider. Each gets
140
159
  `{ url, label, events, previous? }`, where `events` are only the ones some
@@ -237,38 +237,52 @@ with `outcome: 'success' | 'denied'`, the decider under `userIdentity`, and the
237
237
  run, reason and refusal in `metadata`. Wire an `audit` service to keep it; a
238
238
  project without one records nothing and is otherwise unaffected.
239
239
 
240
- ### Error handling: `onError`, never try/catch
240
+ ### Failure: `compensate`, never try/catch
241
241
 
242
242
  **Do not wrap steps in try/catch.** The DSL extractor serialises the body into a
243
243
  step graph, and a `catch` block is control flow it cannot represent — so the
244
- graph would no longer describe what actually runs, which is the whole point of
245
- the DSL mode. This is a settled design decision, not a temporary limitation.
244
+ graph would no longer describe what actually runs. This is a settled design
245
+ decision, not a temporary limitation.
246
246
 
247
- Use the `onError` step option instead: it names an RPC to invoke when the step
248
- has failed _after_ exhausting its retries.
247
+ Declare how a function is undone **on the function itself**. When a later step
248
+ fails (after its retries), the engine runs the `compensate` of every earlier
249
+ step that completed, newest first, then the failed step's own:
249
250
 
250
251
  ```typescript
251
- await workflow.do(
252
- 'Charge',
253
- 'chargePayment',
254
- { orderId },
255
- {
256
- retries: 3,
257
- retryDelay: '1s',
258
- onError: 'refundReservation', // compensation RPC
259
- }
260
- )
252
+ export const chargePayment = pikkuFunc({
253
+ func: async ({ payments }, { orderId }) => payments.charge(orderId),
254
+ compensate: async ({ payments }, { orderId }, { workflow }) => {
255
+ const { ok, output } = workflow!.compensatingFor!
256
+ // ok: false → the charge itself failed; output is null, `error` is set
257
+ if (ok) await payments.refund(output.chargeId)
258
+ },
259
+ })
261
260
  ```
262
261
 
263
- The handler receives `{ error: { message } }`, and the original error is still
264
- thrown afterwards — so the workflow still fails. `onError` is **compensation, not
265
- recovery**: it exists to undo work, not to swallow the failure and carry on. If
266
- you genuinely need to branch on a failure, have the step return a result object
267
- (`{ success: false, reason }`) and branch on that, the way the `processOrder`
268
- example branches on `payment.success`.
269
-
270
- Full step options: `description`, `retries`, `retryDelay`, `onError` (plus
271
- `actor`, which is scenario-only — see `pikku-scenario`).
262
+ - `compensate` gets the **same input** as the forward call. `wire.workflow.compensatingFor`
263
+ is `{ ok: true, output, stepName }` or `{ ok: false, output: null, error, stepName }`.
264
+ - It is never callable over HTTP, MCP or RPC — only the engine runs it, as the
265
+ durable step `<step>:compensate` with the same retry defaults as forward steps.
266
+ - Opt a call site out with `{ compensate: false }` (graph nodes likewise).
267
+ - A run ends `compensated` (everything undone), `compensation_failed` (a
268
+ compensation ran out of retries; `stuckSteps` names them — steps that ran
269
+ before a stuck one are not undone, parallel siblings still are) or `failed`
270
+ (nothing needed undoing).
271
+ - `await workflow.milestone('paid')` bounds the unwind: steps finished before the
272
+ last milestone are kept, and the run ends `compensated` with `restedAt: 'paid'`.
273
+ - A failing child workflow unwinds itself first; a stuck child makes the parent
274
+ `compensation_failed`. Cancelling a run (`cancelRun`) unwinds it the same way,
275
+ children first.
276
+
277
+ To **recover** instead of undo, use a graph node's `recover`:
278
+ `recover: 'nodeId' | ['a','b'] | 'ignore'`. The failure is routed to those nodes
279
+ (`'ignore'` continues to `next` with a null output) and the failing step is not
280
+ compensated. The error arrives as `wire.graph.recoveringFrom`
281
+ (`{ nodeId, stepName, error }`). The DSL has no recovery — branch on a result
282
+ object (`{ success: false, reason }`) instead.
283
+
284
+ Full step options: `description`, `retries`, `retryDelay`, `compensate: false`
285
+ (plus `actor`, which is scenario-only — see `pikku-scenario`).
272
286
 
273
287
  ### Parallel fan-out
274
288
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  ## Step execution: inline vs queue dispatch
4
4
 
5
- Whether a step runs **inline** (same process/session, no queue round-trip) or is **dispatched to the queue** is decided **purely by the step's function** — there is no workflow-level or per-call dispatch flag. `workflow.do(...)` options are only `description`/`retries`/`retryDelay`/`onError`.
5
+ Whether a step runs **inline** (same process/session, no queue round-trip) or is **dispatched to the queue** is decided **purely by the step's function** — there is no workflow-level or per-call dispatch flag. `workflow.do(...)` options are only `description`/`retries`/`retryDelay`/`compensate: false`.
6
6
 
7
7
  - **Steps default to inline.** Most steps don't need their own worker; running them inline avoids a queue round-trip per step, so a normally-started workflow executes its whole chain in one orchestrator pass.
8
8
  - **`workflowQueued: true` opts a function out.** Set it on the **function config** (`pikkuFunc` / `pikkuSessionlessFunc`, same level as `auth`/`expose`) to dispatch that step via the queue — for expensive/long-running steps that deserve their own worker, retry isolation, and concurrency limits. `workflowRetries` and `workflowTimeout` sit alongside it.