@pikku/core 0.12.133 → 0.12.135

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 (46) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/dist/function/compensation-name.d.ts +4 -0
  3. package/dist/function/compensation-name.js +6 -0
  4. package/dist/function/function-meta.types.d.ts +2 -0
  5. package/dist/function/function-runner.js +17 -0
  6. package/dist/function/functions.types.d.ts +7 -0
  7. package/dist/testing/service-tests/queued-workflow-harness.d.ts +61 -0
  8. package/dist/testing/service-tests/queued-workflow-harness.js +151 -0
  9. package/dist/testing/service-tests/workflow-compensation-queued-tests.d.ts +11 -0
  10. package/dist/testing/service-tests/workflow-compensation-queued-tests.js +683 -0
  11. package/dist/testing/service-tests.d.ts +4 -0
  12. package/dist/testing/service-tests.js +4 -0
  13. package/dist/utils/hmac.d.ts +0 -33
  14. package/dist/utils/hmac.js +0 -61
  15. package/dist/wirings/rpc/rpc-runner.js +21 -1
  16. package/dist/wirings/trigger/webhook-source-runner.js +17 -10
  17. package/dist/wirings/trigger/webhook-source.types.d.ts +4 -3
  18. package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +38 -6
  19. package/dist/wirings/workflow/graph/graph-node.d.ts +2 -1
  20. package/dist/wirings/workflow/graph/graph-node.js +2 -1
  21. package/dist/wirings/workflow/graph/graph-runner.d.ts +1 -1
  22. package/dist/wirings/workflow/graph/graph-runner.js +104 -67
  23. package/dist/wirings/workflow/graph/graph-validation.js +2 -2
  24. package/dist/wirings/workflow/graph/workflow-graph.types.d.ts +20 -1
  25. package/dist/wirings/workflow/index.d.ts +1 -1
  26. package/dist/wirings/workflow/pikku-workflow-service.d.ts +13 -0
  27. package/dist/wirings/workflow/pikku-workflow-service.js +113 -143
  28. package/dist/wirings/workflow/run-timeline.d.ts +2 -0
  29. package/dist/wirings/workflow/run-timeline.js +3 -0
  30. package/dist/wirings/workflow/workflow-child-step.d.ts +17 -0
  31. package/dist/wirings/workflow/workflow-child-step.js +31 -0
  32. package/dist/wirings/workflow/workflow-compensation.d.ts +68 -0
  33. package/dist/wirings/workflow/workflow-compensation.js +282 -0
  34. package/dist/wirings/workflow/workflow-constants.d.ts +5 -0
  35. package/dist/wirings/workflow/workflow-constants.js +9 -0
  36. package/dist/wirings/workflow/workflow-dsl-pass.d.ts +12 -0
  37. package/dist/wirings/workflow/workflow-dsl-pass.js +75 -0
  38. package/dist/wirings/workflow/workflow-queue-routing.js +3 -3
  39. package/dist/wirings/workflow/workflow-run-status.js +13 -1
  40. package/dist/wirings/workflow/workflow-status-stream.js +2 -0
  41. package/dist/wirings/workflow/workflow-unwind-plan.d.ts +38 -0
  42. package/dist/wirings/workflow/workflow-unwind-plan.js +120 -0
  43. package/dist/wirings/workflow/workflow.types.d.ts +9 -2
  44. package/knowledge/decisions/internals/workflow-step-compensation-runs-as-its-own-durable-step.md +21 -18
  45. package/package.json +1 -1
  46. package/src/public-surface.json +0 -1
@@ -41,8 +41,12 @@ export interface ServiceTestConfig {
41
41
  leaseService?: () => Promise<LeaseService>;
42
42
  /** A workflow service that fences each step write to the claim that made it. */
43
43
  workflowFencing?: () => Promise<WorkflowFencingHarness>;
44
+ /** A workflow service to run saga compensation and graph `recover` against, driven through a queue. */
45
+ workflowCompensationQueued?: () => Promise<PikkuWorkflowService>;
46
+ workflowCompensationQueuedOptions?: QueuedCompensationOptions;
44
47
  };
45
48
  }
49
+ import { type QueuedCompensationOptions } from './service-tests/workflow-compensation-queued-tests.js';
46
50
  /**
47
51
  * The shared conformance suite every storage backend runs.
48
52
  *
@@ -10,6 +10,7 @@ import { defineAgentRunServiceTests } from './service-tests/agent-run-service-te
10
10
  import { defineSessionStoreTests } from './service-tests/session-store-tests.js';
11
11
  import { defineLeaseServiceTests } from './service-tests/lease-service-tests.js';
12
12
  import { defineWorkflowFencingTests } from './service-tests/workflow-fencing-tests.js';
13
+ import { defineWorkflowCompensationQueuedTests, } from './service-tests/workflow-compensation-queued-tests.js';
13
14
  /**
14
15
  * The shared conformance suite every storage backend runs.
15
16
  *
@@ -27,6 +28,9 @@ export function defineServiceTests(config) {
27
28
  if (services.workflowService) {
28
29
  defineWorkflowServiceTests(name, services.workflowService);
29
30
  }
31
+ if (services.workflowCompensationQueued) {
32
+ defineWorkflowCompensationQueuedTests(name, services.workflowCompensationQueued, services.workflowCompensationQueuedOptions);
33
+ }
30
34
  if (services.workflowRunService) {
31
35
  defineWorkflowRunServiceTests(name, services.workflowRunService);
32
36
  }
@@ -1,4 +1,3 @@
1
- import type { CredentialService } from '../services/credential-service.js';
2
1
  /**
3
2
  * HMAC-SHA256 of a payload, hex-encoded. Senders wrap this in their own scheme
4
3
  * prefix (`sha256=`, `v0=`, …).
@@ -12,7 +11,6 @@ export declare function timingSafeStringEqual(a: string, b: string): boolean;
12
11
  export type WebhookPayload = string | Uint8Array;
13
12
  export type HmacAlgorithm = 'sha1' | 'sha256' | 'sha512';
14
13
  export type SecretEncoding = 'utf8' | 'hex' | 'base64';
15
- type SecretSource = string | null | (() => Promise<string | null>);
16
14
  /** The HMAC of `payload` under `secret`, for handshakes that answer with a digest. */
17
15
  export declare function hmacDigest(secret: string, algorithm: HmacAlgorithm, payload: WebhookPayload, encoding: 'hex' | 'base64', secretEncoding?: SecretEncoding): string;
18
16
  /** Whether `signature` is the HMAC of `payload` under `secret`, compared in constant time. */
@@ -22,34 +20,3 @@ export declare function verifyPublicKeySignature(publicKey: string, signature: s
22
20
  algorithm?: string;
23
21
  dsaEncoding?: 'der' | 'ieee-p1363';
24
22
  }): boolean;
25
- /**
26
- * @deprecated Declare `credential` and `verify` on `wireTriggerWebhookSource`,
27
- * which checks every request before `receive` runs, or use the functions above.
28
- */
29
- export declare class WebhookSigningSecret {
30
- private readonly provider;
31
- private readonly secret;
32
- constructor(provider: string, secret: SecretSource);
33
- /**
34
- * The secret a `setup` step or a handshake stored in the credential store,
35
- * so a new one takes effect without a deploy.
36
- */
37
- static fromCredential(provider: string, credentials: CredentialService | undefined, name: string): WebhookSigningSecret;
38
- get configured(): boolean;
39
- /** The secret as it is now, to check one delivery against. */
40
- load(): Promise<WebhookSigningSecret>;
41
- /** For handshakes that answer with a digest, such as Zoom's URL validation. */
42
- hmac(algorithm: HmacAlgorithm, payload: WebhookPayload, encoding: 'hex' | 'base64', secretEncoding?: SecretEncoding): string;
43
- /** Throws unless `signature` is the HMAC of `payload` under the secret. */
44
- verifyHmac(signature: string | undefined, algorithm: HmacAlgorithm, payload: WebhookPayload, encoding: 'hex' | 'base64', secretEncoding?: SecretEncoding): void;
45
- /** For providers that send the shared secret itself rather than a signature. */
46
- verifyToken(token: string | undefined): void;
47
- /** For providers that sign with a private key: the secret is their public key, as PEM. */
48
- verifyPublicKey(signature: string | undefined, payload: WebhookPayload, options?: {
49
- algorithm?: string;
50
- dsaEncoding?: 'der' | 'ieee-p1363';
51
- }): void;
52
- private require;
53
- private rejected;
54
- }
55
- export {};
@@ -1,5 +1,4 @@
1
1
  import { createHmac, createVerify, timingSafeEqual } from 'node:crypto';
2
- import { UnauthorizedError } from '../errors/errors.js';
3
2
  /**
4
3
  * HMAC-SHA256 of a payload, hex-encoded. Senders wrap this in their own scheme
5
4
  * prefix (`sha256=`, `v0=`, …).
@@ -43,63 +42,3 @@ export function verifyPublicKeySignature(publicKey, signature, payload, options
43
42
  return false;
44
43
  }
45
44
  }
46
- /**
47
- * @deprecated Declare `credential` and `verify` on `wireTriggerWebhookSource`,
48
- * which checks every request before `receive` runs, or use the functions above.
49
- */
50
- export class WebhookSigningSecret {
51
- provider;
52
- secret;
53
- constructor(provider, secret) {
54
- this.provider = provider;
55
- this.secret = secret;
56
- }
57
- /**
58
- * The secret a `setup` step or a handshake stored in the credential store,
59
- * so a new one takes effect without a deploy.
60
- */
61
- static fromCredential(provider, credentials, name) {
62
- return new WebhookSigningSecret(provider, async () => credentials ? credentials.get(name) : null);
63
- }
64
- get configured() {
65
- return typeof this.secret === 'string';
66
- }
67
- /** The secret as it is now, to check one delivery against. */
68
- async load() {
69
- if (typeof this.secret !== 'function') {
70
- return this;
71
- }
72
- return new WebhookSigningSecret(this.provider, await this.secret());
73
- }
74
- /** For handshakes that answer with a digest, such as Zoom's URL validation. */
75
- hmac(algorithm, payload, encoding, secretEncoding = 'utf8') {
76
- return hmacDigest(this.require(), algorithm, payload, encoding, secretEncoding);
77
- }
78
- /** Throws unless `signature` is the HMAC of `payload` under the secret. */
79
- verifyHmac(signature, algorithm, payload, encoding, secretEncoding = 'utf8') {
80
- if (!verifyHmacSignature(this.require(), signature, algorithm, payload, encoding, secretEncoding)) {
81
- throw this.rejected();
82
- }
83
- }
84
- /** For providers that send the shared secret itself rather than a signature. */
85
- verifyToken(token) {
86
- if (!token || !timingSafeStringEqual(token, this.require())) {
87
- throw this.rejected();
88
- }
89
- }
90
- /** For providers that sign with a private key: the secret is their public key, as PEM. */
91
- verifyPublicKey(signature, payload, options = {}) {
92
- if (!verifyPublicKeySignature(this.require(), signature, payload, options)) {
93
- throw this.rejected();
94
- }
95
- }
96
- require() {
97
- if (typeof this.secret !== 'string') {
98
- throw new UnauthorizedError(`The ${this.provider} webhook receiver has no signing secret`);
99
- }
100
- return this.secret;
101
- }
102
- rejected() {
103
- return new UnauthorizedError(`Invalid ${this.provider} webhook signature`);
104
- }
105
- }
@@ -1,3 +1,4 @@
1
+ import { forwardStepName, isCompensationStepName, } from '../../function/compensation-name.js';
1
2
  import { runPikkuFunc } from '../../function/function-runner.js';
2
3
  import { addonInstanceForNamespace } from '../addon/addon-runner.js';
3
4
  import { isAddonFunctionExposed } from '../addon/wire-addon.js';
@@ -86,6 +87,15 @@ const resolvePikkuFunction = (rpcName, packageName = null) => {
86
87
  };
87
88
  }
88
89
  }
90
+ if (!rpcMeta && isCompensationStepName(rpcName)) {
91
+ const forward = forwardStepName(rpcName);
92
+ const funcs = pikkuState(null, 'function', 'functions');
93
+ const forwardId = rpc[forward] ?? (funcs.has(forward) ? forward : undefined);
94
+ const forwardFunc = forwardId ? funcs.get(forwardId) : undefined;
95
+ if (forwardId && forwardFunc?.compensate) {
96
+ return { pikkuFuncId: `${forwardId}:compensate`, packageName: null };
97
+ }
98
+ }
89
99
  if (!rpcMeta) {
90
100
  throw new RPCNotFoundError(rpcName);
91
101
  }
@@ -203,7 +213,17 @@ export class ContextAwareRPCService {
203
213
  };
204
214
  }
205
215
  const addonFunctionMeta = pikkuState(resolved.package, 'function', 'meta');
206
- const funcMeta = addonFunctionMeta[resolved.function];
216
+ let funcMeta = addonFunctionMeta[resolved.function];
217
+ if (!funcMeta && isCompensationStepName(resolved.function)) {
218
+ const forwardName = forwardStepName(resolved.function);
219
+ const forwardMeta = addonFunctionMeta[forwardName];
220
+ if (forwardMeta?.compensate) {
221
+ funcMeta = {
222
+ ...forwardMeta,
223
+ pikkuFuncId: `${forwardMeta.pikkuFuncId || forwardName}:compensate`,
224
+ };
225
+ }
226
+ }
207
227
  if (!funcMeta) {
208
228
  return NOT_RESOLVED;
209
229
  }
@@ -70,7 +70,7 @@ const signedWith = async (verify, request, secret, services) => {
70
70
  return value?.startsWith(prefix) ? value.slice(prefix.length) : undefined;
71
71
  };
72
72
  if ('hmac' in verify) {
73
- const { header: name, prefix, algorithm, encoding, secretEncoding } = verify.hmac;
73
+ const { header: name, prefix, algorithm, encoding, secretEncoding, } = verify.hmac;
74
74
  return verifyHmacSignature(secret, header(name, prefix), algorithm, request.body, encoding, secretEncoding);
75
75
  }
76
76
  if ('token' in verify) {
@@ -81,23 +81,30 @@ const signedWith = async (verify, request, secret, services) => {
81
81
  return verifyPublicKeySignature(secret, header(name), request.body, options);
82
82
  };
83
83
  /**
84
- * Whether the request was checked against the source's signing secret. Throws
85
- * when it was checked and failed. A source without `verify` checks in its own
86
- * `receive`, and a request without a body has nothing signed to check.
84
+ * Whether the request was signed with the source's secret. A request with a
85
+ * body is refused when it was not. A request without one — a HEAD probe, a
86
+ * validation token in the query — only comes back unverified, so `receive`
87
+ * can answer it but not dispatch from it. A source without `verify` checks in
88
+ * its own `receive`.
87
89
  */
88
90
  const verifyRequest = async (source, request, services) => {
89
91
  if (!source?.verify)
90
92
  return true;
91
- if (request.body.length === 0)
92
- return false;
93
+ const bodiless = request.body.length === 0;
93
94
  const secret = await services.credentialService?.get(source.credential ?? webhookSecretCredentialName(source.name));
94
95
  if (typeof secret !== 'string' || !secret) {
96
+ if (bodiless)
97
+ return false;
95
98
  throw new UnauthorizedError(`The ${source.name} webhook source has no signing secret`);
96
99
  }
97
- if (!(await signedWith(source.verify, request, secret, services))) {
98
- throw new UnauthorizedError(`Invalid ${source.name} webhook signature`);
99
- }
100
- return true;
100
+ const signed = await signedWith(source.verify, request, secret, services).catch((error) => {
101
+ if (bodiless)
102
+ return false;
103
+ throw error;
104
+ });
105
+ if (signed || bodiless)
106
+ return signed;
107
+ throw new UnauthorizedError(`Invalid ${source.name} webhook signature`);
101
108
  };
102
109
  const validateEvents = async (source, events, logger) => {
103
110
  const valid = [];
@@ -120,10 +120,11 @@ export type CoreTriggerWebhookSource<Events extends Record<string, StandardSchem
120
120
  /** What the secret is and where to find it, shown to whoever has to set it. */
121
121
  credentialDescription?: string;
122
122
  /**
123
- * Checked before `receive` on every request with a body. A request is
123
+ * Checked before `receive` on every request. A request with a body is
124
124
  * refused when the credential is not set or the signature does not match.
125
- * A request without a body — a HEAD probe, a validation token in the query —
126
- * reaches `receive` unchecked, and may be answered but dispatches nothing.
125
+ * A request without a body that fails the check — a HEAD probe, a
126
+ * validation token in the query — still reaches `receive`, which may answer
127
+ * it, but any events it returns are refused.
127
128
  */
128
129
  verify?: WebhookVerify;
129
130
  /** Omitted: the JSON body is one event dispatched to the trigger named `<name>`. */
@@ -3,6 +3,7 @@
3
3
  * These types define the step-based workflow format extracted by the inspector
4
4
  */
5
5
  import type { StandardSchemaV1 } from '@standard-schema/spec';
6
+ import type { SerializedError } from '../../../errors/serialized-error.js';
6
7
  import type { WorkflowRun } from '../workflow.types.js';
7
8
  import type { ScenarioPersona } from '../../../services/personas-service.js';
8
9
  import type { ScenarioStepOptions, ScenarioStepPhase, ScenarioSurface } from '../scenario-step.types.js';
@@ -17,12 +18,11 @@ export interface WorkflowStepOptions {
17
18
  /** Delay between retry attempts (e.g., '1s', '2s', '2min') */
18
19
  retryDelay?: string | number;
19
20
  /**
20
- * RPC to invoke for compensation when this step fails after exhausting its
21
- * retries. Mirrors a graph node's `onError`: the handler receives
22
- * `{ error: { message } }` and the original error is still thrown, so the
23
- * workflow fails — this is compensation, not recovery.
21
+ * Set to `false` to leave this step out of an unwind even though its
22
+ * function declares a `compensate`. It cannot substitute a different
23
+ * compensation.
24
24
  */
25
- onError?: string;
25
+ compensate?: false;
26
26
  /**
27
27
  * Run this step as an actor (scenarios). The RPC is sent through the
28
28
  * actor's authenticated client over the REAL transport — never dispatched
@@ -93,6 +93,10 @@ export type WorkflowWireSleep = (stepName: string, duration: string) => Promise<
93
93
  * loops, like dynamic `do()` step names.
94
94
  */
95
95
  export type WorkflowWireSuspend = (reason: string) => Promise<void>;
96
+ /**
97
+ * Type signature for workflow.milestone() - used by inspector.
98
+ */
99
+ export type WorkflowWireMilestone = (name: string) => Promise<void>;
96
100
  /**
97
101
  * Who is allowed to answer an approval gate, relative to the user who started
98
102
  * the run.
@@ -436,6 +440,14 @@ export interface SuspendStepMeta {
436
440
  /** Reason string passed to workflow.suspend() — becomes the durable step key */
437
441
  reason: string;
438
442
  }
443
+ /**
444
+ * Milestone step metadata (workflow.milestone())
445
+ */
446
+ export interface MilestoneStepMeta {
447
+ type: 'milestone';
448
+ /** Name passed to workflow.milestone() — where a compensating run rests */
449
+ name: string;
450
+ }
439
451
  /**
440
452
  * Approval step metadata (workflow.approval())
441
453
  */
@@ -485,7 +497,7 @@ export interface ArrayPredicateStepMeta {
485
497
  /**
486
498
  * Workflow step metadata (extracted by inspector)
487
499
  */
488
- export type WorkflowStepMeta = RpcStepMeta | ScenarioStepMeta | BranchStepMeta | ParallelGroupStepMeta | FanoutStepMeta | ReturnStepMeta | InlineStepMeta | SleepStepMeta | CancelStepMeta | SuspendStepMeta | ApprovalStepMeta | SwitchStepMeta | FilterStepMeta | ArrayPredicateStepMeta | SetStepMeta;
500
+ export type WorkflowStepMeta = RpcStepMeta | ScenarioStepMeta | BranchStepMeta | ParallelGroupStepMeta | FanoutStepMeta | ReturnStepMeta | InlineStepMeta | SleepStepMeta | CancelStepMeta | SuspendStepMeta | MilestoneStepMeta | ApprovalStepMeta | SwitchStepMeta | FilterStepMeta | ArrayPredicateStepMeta | SetStepMeta;
489
501
  /**
490
502
  * Workflow step wire context for RPC functions
491
503
  * Provides step-level metadata including retry attempt tracking
@@ -520,7 +532,22 @@ export interface WorkflowStepWire {
520
532
  * Workflow wire object for DSL workflows
521
533
  * Provides workflow-specific capabilities to function execution
522
534
  */
535
+ export type CompensatingFor<Out = unknown> = {
536
+ ok: true;
537
+ output: Out;
538
+ stepName?: string;
539
+ } | {
540
+ ok: false;
541
+ output: null;
542
+ error: SerializedError;
543
+ stepName?: string;
544
+ };
523
545
  export interface PikkuWorkflowWire {
546
+ /**
547
+ * Set only while a function's `compensate` is running: the outcome of the
548
+ * forward call being undone. `undefined` on a forward run.
549
+ */
550
+ compensatingFor?: CompensatingFor;
524
551
  /** The workflow name */
525
552
  name: string;
526
553
  /** The current run ID */
@@ -535,6 +562,11 @@ export interface PikkuWorkflowWire {
535
562
  sleep: WorkflowWireSleep;
536
563
  /** Suspend workflow until explicitly resumed */
537
564
  suspend: WorkflowWireSuspend;
565
+ /**
566
+ * A named checkpoint. On failure the unwind stops at the last milestone
567
+ * reached and the run rests there. The name must be a string literal.
568
+ */
569
+ milestone: WorkflowWireMilestone;
538
570
  /** Suspend workflow until a human records a decision against this gate */
539
571
  approval: WorkflowWireApproval;
540
572
  }
@@ -27,7 +27,8 @@ type GraphNodeConfigMap<FuncMap extends Record<string, string>, RPCMap extends R
27
27
  /** How the per-item instances run. Defaults to 'parallel'. */
28
28
  mode?: ForEachMode;
29
29
  input?: (ref: <N extends Extract<keyof FuncMap, string>, P extends keyof ComputeNodeOutputs<FuncMap, RPCMap>[N] & string>(nodeId: N, path: P) => TypedRef<ComputeNodeOutputs<FuncMap, RPCMap>[N][P]>, template: TemplateFn, $item: ItemFn) => InputWithRefs<ComputeNodeInputs<FuncMap, RPCMap>[K]>;
30
- onError?: Extract<keyof FuncMap, string> | Extract<keyof FuncMap, string>[];
30
+ recover?: Extract<keyof FuncMap, string> | Extract<keyof FuncMap, string>[] | 'ignore';
31
+ compensate?: false;
31
32
  retries?: number;
32
33
  retryDelay?: string | number;
33
34
  notes?: string;
@@ -20,7 +20,8 @@ export function createGraph() {
20
20
  mode: def?.mode,
21
21
  input: def?.input,
22
22
  next: def?.next,
23
- onError: def?.onError,
23
+ recover: def?.recover,
24
+ compensate: def?.compensate,
24
25
  retries: def?.retries,
25
26
  retryDelay: def?.retryDelay,
26
27
  };
@@ -8,7 +8,7 @@ export declare class ChildWorkflowStartedException extends Error {
8
8
  constructor(parentRunId: string, stepId: string, childRunId: string);
9
9
  }
10
10
  export declare function stripInstanceOrdinal(name: string): string;
11
- export declare function continueGraph(workflowService: PikkuWorkflowService, runId: string, graphName: string, overrideMeta?: WorkflowRuntimeMeta): Promise<void>;
11
+ export declare function continueGraph(workflowService: PikkuWorkflowService, runId: string, graphName: string, overrideMeta?: WorkflowRuntimeMeta, rpcService?: any): Promise<void>;
12
12
  export declare function executeGraphStep(workflowService: PikkuWorkflowService, rpcService: any, runId: string, stepId: string, nodeId: string, rpcName: string, data: any, graphName: string): Promise<any>;
13
13
  export declare function runFromMeta(workflowService: PikkuWorkflowService, runId: string, meta: WorkflowRuntimeMeta, _rpcService: any): Promise<void>;
14
14
  export declare function runWorkflowGraph(workflowService: PikkuWorkflowService, graphName: string, triggerInput: any, rpcService?: any, inline?: boolean, startNode?: string, wire?: WorkflowRunWire, overrideMeta?: WorkflowRuntimeMeta): Promise<{