@pikku/core 0.12.130 → 0.12.134

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 (62) hide show
  1. package/CHANGELOG.md +101 -0
  2. package/dist/middleware-runner.d.ts +4 -0
  3. package/dist/middleware-runner.js +52 -16
  4. package/dist/services/in-memory-lease-service.d.ts +13 -0
  5. package/dist/services/in-memory-lease-service.js +49 -0
  6. package/dist/services/in-memory-workflow-service.d.ts +11 -3
  7. package/dist/services/in-memory-workflow-service.js +53 -23
  8. package/dist/services/index.d.ts +2 -0
  9. package/dist/services/index.js +2 -0
  10. package/dist/services/lease-service.d.ts +61 -0
  11. package/dist/services/lease-service.js +110 -0
  12. package/dist/services/workflow-service.d.ts +1 -1
  13. package/dist/testing/index.d.ts +1 -0
  14. package/dist/testing/service-tests/lease-service-tests.d.ts +3 -0
  15. package/dist/testing/service-tests/lease-service-tests.js +138 -0
  16. package/dist/testing/service-tests/workflow-fencing-tests.d.ts +18 -0
  17. package/dist/testing/service-tests/workflow-fencing-tests.js +216 -0
  18. package/dist/testing/service-tests.d.ts +5 -0
  19. package/dist/testing/service-tests.js +8 -0
  20. package/dist/types/core.types.d.ts +12 -8
  21. package/dist/utils/hmac.d.ts +14 -10
  22. package/dist/utils/hmac.js +29 -25
  23. package/dist/wirings/http/pikku-fetch-http-response.js +13 -0
  24. package/dist/wirings/secret/derive-oauth2-app-secrets.js +1 -0
  25. package/dist/wirings/secret/secret.types.d.ts +6 -0
  26. package/dist/wirings/trigger/index.d.ts +2 -2
  27. package/dist/wirings/trigger/index.js +1 -1
  28. package/dist/wirings/trigger/webhook-source-runner.js +52 -1
  29. package/dist/wirings/trigger/webhook-source.types.d.ts +50 -0
  30. package/dist/wirings/trigger/webhook-source.types.js +2 -0
  31. package/dist/wirings/workflow/graph/graph-runner.js +33 -14
  32. package/dist/wirings/workflow/index.d.ts +4 -3
  33. package/dist/wirings/workflow/index.js +3 -2
  34. package/dist/wirings/workflow/pikku-workflow-service.d.ts +57 -18
  35. package/dist/wirings/workflow/pikku-workflow-service.js +143 -140
  36. package/dist/wirings/workflow/workflow-constants.d.ts +28 -1
  37. package/dist/wirings/workflow/workflow-constants.js +28 -1
  38. package/dist/wirings/workflow/workflow-errors.d.ts +21 -0
  39. package/dist/wirings/workflow/workflow-errors.js +38 -0
  40. package/dist/wirings/workflow/workflow-queue-routing.d.ts +4 -2
  41. package/dist/wirings/workflow/workflow-queue-routing.js +9 -1
  42. package/dist/wirings/workflow/workflow-run-lease.d.ts +10 -0
  43. package/dist/wirings/workflow/workflow-run-lease.js +25 -0
  44. package/dist/wirings/workflow/workflow-run-status.d.ts +4 -0
  45. package/dist/wirings/workflow/workflow-run-status.js +37 -0
  46. package/dist/wirings/workflow/workflow-status-stream.d.ts +2 -1
  47. package/dist/wirings/workflow/workflow-status-stream.js +18 -1
  48. package/dist/wirings/workflow/workflow-step-claim.d.ts +13 -1
  49. package/dist/wirings/workflow/workflow-step-claim.js +29 -5
  50. package/dist/wirings/workflow/workflow-step-lease.d.ts +25 -0
  51. package/dist/wirings/workflow/workflow-step-lease.js +53 -0
  52. package/dist/wirings/workflow/workflow-step-retry.d.ts +14 -0
  53. package/dist/wirings/workflow/workflow-step-retry.js +33 -0
  54. package/dist/wirings/workflow/workflow-version-fallback.d.ts +11 -0
  55. package/dist/wirings/workflow/workflow-version-fallback.js +30 -0
  56. package/dist/wirings/workflow/workflow.types.d.ts +17 -0
  57. package/knowledge/decisions/internals/a-held-run-is-woken-later-not-retried.md +28 -0
  58. package/knowledge/decisions/internals/index.md +2 -1
  59. package/knowledge/decisions/internals/the-in-memory-workflow-service-is-inline-only-and-single-process.md +2 -2
  60. package/knowledge/decisions/internals/workflow-step-lock-is-held-only-to-claim-the-step.md +12 -5
  61. package/package.json +1 -1
  62. package/src/public-surface.json +18 -1
@@ -1,7 +1,7 @@
1
1
  export { wireTrigger, wireTriggerSource } from './trigger-runner.js';
2
2
  export { wireTriggerWebhookSource, receiveWebhookSourceRequest, dispatchWebhookSourceJob, runWebhookSourceLifecycle, declaredTriggerSources, reconcileTriggerSources, enableTriggerSource, disableTriggerSource, reconcileWebhookRegistrations, teardownTriggerSources, subscribedWebhookEvents, } from './webhook-source-runner.js';
3
3
  export type { OrphanedWebhookRegistration, WebhookRegistration, WebhookRegistrations, WebhookSourceOutcome, } from './webhook-source-runner.js';
4
- export { PIKKU_INCOMING_WEBHOOK_QUEUE_NAME } from './webhook-source.types.js';
5
- export type { CoreTriggerWebhookSource, TriggerEvent, WebhookCheckResult, WebhookLifecycleInput, WebhookReceiveResult, WebhookRequest, WebhookSetupResult, WebhookSourceJob, WebhookSourceMeta, WebhookSourceMethod, WebhookSourcesMeta, WebhookSourceState, WebhookTeardownInput, WebhookTeardownResult, } from './webhook-source.types.js';
4
+ export { PIKKU_INCOMING_WEBHOOK_QUEUE_NAME, webhookSecretCredentialName, } from './webhook-source.types.js';
5
+ export type { CoreTriggerWebhookSource, TriggerEvent, WebhookCheckResult, WebhookLifecycleInput, WebhookReceiveResult, WebhookRequest, WebhookSetupResult, WebhookSourceJob, WebhookSourceMeta, WebhookSourceMethod, WebhookSourcesMeta, WebhookSourceState, WebhookTeardownInput, WebhookTeardownResult, WebhookVerify, } from './webhook-source.types.js';
6
6
  export { PikkuTriggerService } from './pikku-trigger-service.js';
7
7
  export type { TriggerMeta, TriggerSourceMeta, CorePikkuTriggerFunction, CorePikkuTriggerFunctionConfig, CoreTrigger, } from './trigger.types.js';
@@ -1,4 +1,4 @@
1
1
  export { wireTrigger, wireTriggerSource } from './trigger-runner.js';
2
2
  export { wireTriggerWebhookSource, receiveWebhookSourceRequest, dispatchWebhookSourceJob, runWebhookSourceLifecycle, declaredTriggerSources, reconcileTriggerSources, enableTriggerSource, disableTriggerSource, reconcileWebhookRegistrations, teardownTriggerSources, subscribedWebhookEvents, } from './webhook-source-runner.js';
3
- export { PIKKU_INCOMING_WEBHOOK_QUEUE_NAME } from './webhook-source.types.js';
3
+ export { PIKKU_INCOMING_WEBHOOK_QUEUE_NAME, webhookSecretCredentialName, } from './webhook-source.types.js';
4
4
  export { PikkuTriggerService } from './pikku-trigger-service.js';
@@ -1,6 +1,8 @@
1
1
  import { getSingletonServices, pikkuState } from '../../pikku-state.js';
2
2
  import { addFunction, runPikkuFunc } from '../../function/function-runner.js';
3
- import { PikkuMissingMetaError } from '../../errors/errors.js';
3
+ import { PikkuMissingMetaError, UnauthorizedError, } from '../../errors/errors.js';
4
+ import { timingSafeStringEqual, verifyHmacSignature, verifyPublicKeySignature, } from '../../utils/hmac.js';
5
+ import { webhookSecretCredentialName } from './webhook-source.types.js';
4
6
  const LIFECYCLE = ['receive', 'check', 'setup', 'teardown'];
5
7
  export const wireTriggerWebhookSource = (source) => {
6
8
  const meta = pikkuState(null, 'trigger', 'webhookSourceMeta')[source.name];
@@ -59,6 +61,51 @@ const readRequest = async (http) => {
59
61
  query,
60
62
  };
61
63
  };
64
+ const signedWith = async (verify, request, secret, services) => {
65
+ if (typeof verify === 'function') {
66
+ return await verify(request, secret, services);
67
+ }
68
+ const header = (name, prefix = '') => {
69
+ const value = request.headers[name.toLowerCase()];
70
+ return value?.startsWith(prefix) ? value.slice(prefix.length) : undefined;
71
+ };
72
+ if ('hmac' in verify) {
73
+ const { header: name, prefix, algorithm, encoding, secretEncoding, } = verify.hmac;
74
+ return verifyHmacSignature(secret, header(name, prefix), algorithm, request.body, encoding, secretEncoding);
75
+ }
76
+ if ('token' in verify) {
77
+ const token = header(verify.token.header, verify.token.prefix);
78
+ return !!token && timingSafeStringEqual(token, secret);
79
+ }
80
+ const { header: name, ...options } = verify.publicKey;
81
+ return verifyPublicKeySignature(secret, header(name), request.body, options);
82
+ };
83
+ /**
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`.
89
+ */
90
+ const verifyRequest = async (source, request, services) => {
91
+ if (!source?.verify)
92
+ return true;
93
+ const bodiless = request.body.length === 0;
94
+ const secret = await services.credentialService?.get(source.credential ?? webhookSecretCredentialName(source.name));
95
+ if (typeof secret !== 'string' || !secret) {
96
+ if (bodiless)
97
+ return false;
98
+ throw new UnauthorizedError(`The ${source.name} webhook source has no signing secret`);
99
+ }
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`);
108
+ };
62
109
  const validateEvents = async (source, events, logger) => {
63
110
  const valid = [];
64
111
  for (const event of events) {
@@ -91,6 +138,7 @@ export const receiveWebhookSourceRequest = async (sourceName, wire) => {
91
138
  }
92
139
  const source = pikkuState(null, 'trigger', 'webhookSources').get(sourceName);
93
140
  const request = await readRequest(wire.http);
141
+ const verified = await verifyRequest(source, request, singletonServices);
94
142
  const result = meta.receive
95
143
  ? await runSourceStep(singletonServices, sourceName, meta.receive, request)
96
144
  : {
@@ -112,6 +160,9 @@ export const receiveWebhookSourceRequest = async (sourceName, wire) => {
112
160
  : JSON.stringify(body);
113
161
  return new Response(text, { status, headers });
114
162
  }
163
+ if (!verified && result.events.length > 0) {
164
+ throw new UnauthorizedError(`The ${sourceName} webhook source received events in an unsigned request`);
165
+ }
115
166
  const triggers = pikkuState(null, 'trigger', 'meta');
116
167
  const listened = result.events.filter((event) => triggers[triggerName(sourceName, event.name)]);
117
168
  const events = await validateEvents(source, listened, singletonServices.logger);
@@ -1,6 +1,9 @@
1
1
  import type { StandardSchemaV1 } from '@standard-schema/spec';
2
2
  import type { CorePikkuFunctionConfig } from '../../function/functions.types.js';
3
+ import type { HmacAlgorithm, SecretEncoding } from '../../utils/hmac.js';
3
4
  export declare const PIKKU_INCOMING_WEBHOOK_QUEUE_NAME = "pikku-incoming-webhooks";
5
+ /** `github` → `githubWebhookSecret`, `microsoft-outlook` → `microsoftOutlookWebhookSecret`. */
6
+ export declare const webhookSecretCredentialName: (source: string) => string;
4
7
  /** What every trigger source produces, whichever way it arrives. */
5
8
  export type TriggerEvent<Name extends string = string, Data = unknown> = {
6
9
  /** The provider's event type; dispatched to the trigger named `<source>:<name>`. Empty dispatches to `<source>`. */
@@ -64,6 +67,38 @@ export type WebhookTeardownInput = {
64
67
  export type WebhookTeardownResult = {
65
68
  status: 'deleted' | 'absent';
66
69
  };
70
+ /**
71
+ * How a source's requests are signed. The declared forms cover a signature
72
+ * over the raw body in one header; anything else — a timestamp in the signed
73
+ * payload, form fields, a URL — is a function that says whether the request is
74
+ * genuine, using the helpers in `@pikku/core/hmac`.
75
+ */
76
+ export type WebhookVerify<Services = any> = {
77
+ hmac: {
78
+ header: string;
79
+ /** Stripped from the header before comparing, such as `sha256=`. */
80
+ prefix?: string;
81
+ algorithm: HmacAlgorithm;
82
+ encoding: 'hex' | 'base64';
83
+ /** How the stored secret is encoded. Defaults to `utf8`. */
84
+ secretEncoding?: SecretEncoding;
85
+ };
86
+ }
87
+ /** The provider sends the shared secret itself rather than a signature. */
88
+ | {
89
+ token: {
90
+ header: string;
91
+ prefix?: string;
92
+ };
93
+ }
94
+ /** The provider signs with a private key: the stored secret is its public key, as PEM. */
95
+ | {
96
+ publicKey: {
97
+ header: string;
98
+ algorithm?: string;
99
+ dsaEncoding?: 'der' | 'ieee-p1363';
100
+ };
101
+ } | ((request: WebhookRequest, secret: string, services: Services) => boolean | Promise<boolean>);
67
102
  type SourceFunction<In, Out> = CorePikkuFunctionConfig<any, any> & {
68
103
  func: (services: any, data: In, wire: any) => Promise<Out>;
69
104
  };
@@ -76,6 +111,21 @@ export type CoreTriggerWebhookSource<Events extends Record<string, StandardSchem
76
111
  route?: string;
77
112
  /** What the source can produce. Each event's data is validated against its schema before it is queued. */
78
113
  events?: Events;
114
+ /**
115
+ * The credential holding the signing secret. Defaults to
116
+ * {@link webhookSecretCredentialName}. Declaring `verify` declares this
117
+ * credential too, as a singleton string, so it needs no `defineCredential`.
118
+ */
119
+ credential?: string;
120
+ /** What the secret is and where to find it, shown to whoever has to set it. */
121
+ credentialDescription?: string;
122
+ /**
123
+ * Checked before `receive` on every request with a body. A request is
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.
127
+ */
128
+ verify?: WebhookVerify;
79
129
  /** Omitted: the JSON body is one event dispatched to the trigger named `<name>`. */
80
130
  receive?: SourceFunction<WebhookRequest, WebhookReceiveResult>;
81
131
  check?: SourceFunction<WebhookLifecycleInput, WebhookCheckResult>;
@@ -1 +1,3 @@
1
1
  export const PIKKU_INCOMING_WEBHOOK_QUEUE_NAME = 'pikku-incoming-webhooks';
2
+ /** `github` → `githubWebhookSecret`, `microsoft-outlook` → `microsoftOutlookWebhookSecret`. */
3
+ export const webhookSecretCredentialName = (source) => `${source.replace(/[-_]+([a-z0-9])/gi, (_, c) => c.toUpperCase())}WebhookSecret`;
@@ -1,5 +1,6 @@
1
1
  import { WorkflowAsyncException, WorkflowSuspendedException, } from '../workflow-errors.js';
2
2
  import { DEFAULT_STEP_RETRIES } from '../workflow-constants.js';
3
+ import { runningStepLease } from '../workflow-step-lease.js';
3
4
  import { pikkuState, getSingletonServices } from '../../../pikku-state.js';
4
5
  import { RPCNotFoundError } from '../../rpc/rpc-runner.js';
5
6
  export class ChildWorkflowStartedException extends Error {
@@ -563,11 +564,12 @@ async function createGraphResultReader(workflowService, runId, graphName, nodes,
563
564
  },
564
565
  };
565
566
  }
567
+ const graphStepOptions = (nodeConfig) => ({
568
+ retries: nodeConfig?.retries ?? DEFAULT_STEP_RETRIES,
569
+ retryDelay: nodeConfig?.retryDelay,
570
+ });
566
571
  async function queueGraphNode(workflowService, runId, _graphName, nodeId, rpcName, input, nodeConfig, fromStepName) {
567
- const stepOptions = {
568
- retries: nodeConfig?.retries ?? DEFAULT_STEP_RETRIES,
569
- retryDelay: nodeConfig?.retryDelay,
570
- };
572
+ const stepOptions = graphStepOptions(nodeConfig);
571
573
  await workflowService.insertStepState(runId, nodeId, rpcName, input, stepOptions, fromStepName);
572
574
  await workflowService.queueStepWorker(runId, nodeId, rpcName, input, stepOptions, fromStepName);
573
575
  }
@@ -599,6 +601,32 @@ export async function continueGraph(workflowService, runId, graphName, overrideM
599
601
  const triggerInput = currentRun?.input;
600
602
  const reader = await createGraphResultReader(workflowService, runId, graphName, nodes, triggerInput, instances);
601
603
  const plan = planGraphTransitions(nodes, instances, branchByStep, meta.entryNodeIds ?? [], graphName, reader.fanoutWidths);
604
+ const resolveNodeInput = async (logical, itemIndex) => {
605
+ const node = nodes[logical];
606
+ const referencedNodeIds = extractReferencedNodeIds(node.input).filter((id) => !IGNORED_REFS.has(id));
607
+ const fetchedResults = await reader.read(referencedNodeIds);
608
+ const nodeResults = {
609
+ trigger: triggerInput,
610
+ ...fetchedResults,
611
+ };
612
+ if (itemIndex !== undefined) {
613
+ nodeResults['$item'] = reader.fanoutItems[logical]?.[itemIndex];
614
+ }
615
+ return resolveSerializedInput(node.input, nodeResults);
616
+ };
617
+ // A node whose worker died is still `running`, so the plan counts it in
618
+ // flight and would wait on it forever. Its step already exists: dispatching
619
+ // it again is enough, and the claim makes that the next attempt.
620
+ for (const instance of instances) {
621
+ if (runningStepLease(instance) !== 'lapsed')
622
+ continue;
623
+ const logical = remapStepNamesToNodeIds([instance.stepName], nodes, graphName)[0];
624
+ const node = nodes[logical];
625
+ if (!node?.rpcName)
626
+ continue;
627
+ const itemIndex = splitFanoutInstance(instance.stepName)?.index;
628
+ await workflowService.queueStepWorker(runId, instance.stepName, node.rpcName, await resolveNodeInput(logical, itemIndex), graphStepOptions(node), instance.fromStepName);
629
+ }
602
630
  if (plan.toFire.length === 0) {
603
631
  if (!plan.hasInFlight && !plan.blockedWaiting) {
604
632
  await workflowService.updateRunStatus(runId, 'completed');
@@ -609,16 +637,7 @@ export async function continueGraph(workflowService, runId, graphName, overrideM
609
637
  const node = nodes[fire.logical];
610
638
  if (!node?.rpcName)
611
639
  continue;
612
- const referencedNodeIds = extractReferencedNodeIds(node.input).filter((id) => !IGNORED_REFS.has(id));
613
- const fetchedResults = await reader.read(referencedNodeIds);
614
- const nodeResults = {
615
- trigger: triggerInput,
616
- ...fetchedResults,
617
- };
618
- if (fire.itemIndex !== undefined) {
619
- nodeResults['$item'] = reader.fanoutItems[fire.logical]?.[fire.itemIndex];
620
- }
621
- const resolvedInput = resolveSerializedInput(node.input, nodeResults);
640
+ const resolvedInput = await resolveNodeInput(fire.logical, fire.itemIndex);
622
641
  await queueGraphNode(workflowService, runId, graphName, fire.instanceKey, node.rpcName, resolvedInput, node, fire.fromStepName);
623
642
  }
624
643
  }
@@ -1,6 +1,7 @@
1
1
  export { PikkuWorkflowService } from './pikku-workflow-service.js';
2
- export { WorkflowCancelledException, WorkflowSuspendedException, WorkflowDispatchException, WorkflowNotFoundError, WorkflowRunNotFoundError, WorkflowApprovalResolvedError, WorkflowStepFunctionMismatchError, } from './workflow-errors.js';
3
- export { DEFAULT_STEP_RETRIES } from './workflow-constants.js';
2
+ export { WorkflowCancelledException, WorkflowSuspendedException, WorkflowDispatchException, WorkflowNotFoundError, WorkflowRunNotFoundError, WorkflowApprovalResolvedError, WorkflowStepFunctionMismatchError, WorkflowStepLeaseExpiredError, WorkflowStepSupersededError, } from './workflow-errors.js';
3
+ export { DEFAULT_STEP_RETRIES, DEFAULT_STEP_LEASE_MS, isStepLeaseLive, } from './workflow-constants.js';
4
+ export { leaseAttemptsExhausted } from './workflow-step-claim.js';
4
5
  export { assertWorkflowRunOwner, WorkflowRunForbiddenError, } from './workflow-run-ownership.js';
5
6
  export { WorkflowApprovalForbiddenError } from './workflow-approval-policy.js';
6
7
  export { streamWorkflowRunStatus, type WorkflowStatusStreamParams, } from './workflow-status-stream.js';
@@ -14,7 +15,7 @@ export { addWorkflow } from './dsl/workflow-runner.js';
14
15
  export { template, type TemplateString } from './graph/template.js';
15
16
  export { pikkuWorkflowGraph, type PikkuWorkflowGraphConfig, type PikkuWorkflowGraphResult, } from './graph/wire-workflow-graph.js';
16
17
  export type { WorkflowStepInput as WorkflowStepQueueInput, PikkuWorkflowOrchestratorInput, PikkuWorkflowSleeperInput, } from './workflow-queue-workers.js';
17
- export type { WorkflowService, WorkflowQueueOptions, WorkflowServiceConfig, WorkflowPlannedStep, WorkflowRunWire, WorkflowStatus, WorkflowVersionStatus, StepStatus, WorkflowRun, WorkflowRunStatus, StepState, WorkflowRunService, WorkflowRunMirror, CoreWorkflow, PikkuWorkflow, ContextVariable, WorkflowContext, WorkflowsMeta, WorkflowsRuntimeMeta, } from './workflow.types.js';
18
+ export type { WorkflowService, WorkflowQueueOptions, WorkflowServiceOptions, WorkflowServiceConfig, WorkflowPlannedStep, WorkflowRunWire, WorkflowStatus, WorkflowVersionStatus, StepStatus, WorkflowRun, WorkflowRunStatus, StepState, WorkflowRunService, WorkflowRunMirror, CoreWorkflow, PikkuWorkflow, ContextVariable, WorkflowContext, WorkflowsMeta, WorkflowsRuntimeMeta, } from './workflow.types.js';
18
19
  export type { WorkflowStepOptions, WorkflowWireDoRPC, WorkflowApprovalOptions, ApprovalOutcome, InputSource, OutputBinding, RpcStepMeta, Condition, BranchStepMeta, ParallelGroupStepMeta, FanoutStepMeta, ReturnStepMeta, InlineStepMeta, SleepStepMeta, CancelStepMeta, SuspendStepMeta, ApprovalStepMeta, SetStepMeta, SwitchCaseMeta, SwitchStepMeta, FilterStepMeta, ArrayPredicateStepMeta, WorkflowStepMeta, WorkflowStepWire, PikkuWorkflowWire, } from './workflow.types.js';
19
20
  export { createGraph } from './graph/graph-node.js';
20
21
  export type { GraphNodeConfig, ForEachConfig, ForEachMode, ItemFn, TemplateFn, } from './graph/workflow-graph.types.js';
@@ -1,6 +1,7 @@
1
1
  export { PikkuWorkflowService } from './pikku-workflow-service.js';
2
- export { WorkflowCancelledException, WorkflowSuspendedException, WorkflowDispatchException, WorkflowNotFoundError, WorkflowRunNotFoundError, WorkflowApprovalResolvedError, WorkflowStepFunctionMismatchError, } from './workflow-errors.js';
3
- export { DEFAULT_STEP_RETRIES } from './workflow-constants.js';
2
+ export { WorkflowCancelledException, WorkflowSuspendedException, WorkflowDispatchException, WorkflowNotFoundError, WorkflowRunNotFoundError, WorkflowApprovalResolvedError, WorkflowStepFunctionMismatchError, WorkflowStepLeaseExpiredError, WorkflowStepSupersededError, } from './workflow-errors.js';
3
+ export { DEFAULT_STEP_RETRIES, DEFAULT_STEP_LEASE_MS, isStepLeaseLive, } from './workflow-constants.js';
4
+ export { leaseAttemptsExhausted } from './workflow-step-claim.js';
4
5
  export { assertWorkflowRunOwner, WorkflowRunForbiddenError, } from './workflow-run-ownership.js';
5
6
  export { WorkflowApprovalForbiddenError } from './workflow-approval-policy.js';
6
7
  export { streamWorkflowRunStatus, } from './workflow-status-stream.js';
@@ -7,6 +7,7 @@ import type { ScenarioPersonas } from '../../services/personas-service.js';
7
7
  import type { PikkuRPC } from '../rpc/rpc-types.js';
8
8
  import { type RunTimeline, type ReconstructedRunState } from './run-timeline.js';
9
9
  import type { WorkflowRunEngine, WorkflowRunExtension } from './workflow-run-engine.types.js';
10
+ import type { LeaseService } from '../../services/lease-service.js';
10
11
  export declare abstract class PikkuWorkflowService implements WorkflowService {
11
12
  private runExtension?;
12
13
  private runContexts;
@@ -16,14 +17,21 @@ export declare abstract class PikkuWorkflowService implements WorkflowService {
16
17
  private exitExecution;
17
18
  protected get logger(): import("../../services/logger.js").Logger;
18
19
  protected mirror?: WorkflowRunMirror;
20
+ /**
21
+ * Serialises orchestration passes over a run and claims on a step. A service
22
+ * whose state no other process can reach — in-memory, a Durable Object —
23
+ * has no competitor to exclude, overrides both locks and passes none.
24
+ */
25
+ protected readonly leaseService?: LeaseService;
19
26
  protected readonly queueStrategy: 'per-workflow' | 'shared-groups';
20
27
  protected readonly queueConcurrency: number;
21
28
  protected readonly queueGroupConcurrency: number | GroupConcurrencyConfig;
22
29
  constructor(options?: {
23
30
  wireQueues?: boolean;
24
31
  mirror?: WorkflowRunMirror;
32
+ leaseService?: LeaseService;
25
33
  } & WorkflowQueueOptions);
26
- private mirrored;
34
+ protected mirrored<T>(write: () => Promise<T>, mirror: (mirror: WorkflowRunMirror, written: T) => Promise<void>): Promise<T>;
27
35
  wireQueueWorkers(): void;
28
36
  protected isInline(runId: string): Promise<boolean>;
29
37
  registerInlineRun(runId: string): void;
@@ -51,18 +59,41 @@ export declare abstract class PikkuWorkflowService implements WorkflowService {
51
59
  abstract getStepState(runId: string, stepName: string): Promise<StepState>;
52
60
  setStepRunning(stepId: string): Promise<void>;
53
61
  protected abstract setStepRunningImpl(stepId: string): Promise<void>;
62
+ /**
63
+ * Push the claim on a `running` step forward, so it keeps reading as owned
64
+ * for another lease.
65
+ *
66
+ * Does nothing by default, which leaves a store that records no lease exactly
67
+ * as it was: its `running` steps are owned until they move on their own, and
68
+ * a dispatch that dies takes the step with it. A store that overrides this
69
+ * gains recovery from a lost worker, through both `claimStepForExecution` and
70
+ * `recoverStalledRuns`.
71
+ *
72
+ * `null` releases the lease without ending the step, for a step that is
73
+ * legitimately `running` with no worker on it — one parked on a child run,
74
+ * which the child's completion drives rather than a redispatch.
75
+ * `attempt` fences it to the claim that holds it.
76
+ *
77
+ * The lease runs `leaseMs` from now on the store's own clock, so workers
78
+ * whose clocks disagree still agree on when it lapses. Resolves `false` when
79
+ * the step is no longer this attempt's to renew.
80
+ */
81
+ refreshStepLease(_stepId: string, _leaseMs: number | null, _attempt?: number): Promise<boolean>;
54
82
  setStepScheduled(stepId: string): Promise<void>;
55
83
  protected abstract setStepScheduledImpl(stepId: string): Promise<void>;
56
- setStepResult(stepId: string, result: any): Promise<void>;
57
- protected abstract setStepResultImpl(stepId: string, result: any): Promise<void>;
84
+ /** `attempt` fences it: a newer claim's step throws `WorkflowStepSupersededError`. */
85
+ setStepResult(stepId: string, result: any, attempt?: number): Promise<void>;
86
+ protected abstract setStepResultImpl(stepId: string, result: any, attempt?: number): Promise<void>;
58
87
  setStepChildRunId(stepId: string, childRunId: string): Promise<void>;
59
88
  protected abstract setStepChildRunIdImpl(stepId: string, childRunId: string): Promise<void>;
60
- setStepError(stepId: string, error: Error): Promise<void>;
61
- protected abstract setStepErrorImpl(stepId: string, error: Error): Promise<void>;
89
+ setStepError(stepId: string, error: Error, attempt?: number): Promise<void>;
90
+ protected abstract setStepErrorImpl(stepId: string, error: Error, attempt?: number): Promise<void>;
62
91
  createRetryAttempt(failedStepId: string, status: 'pending' | 'running'): Promise<StepState>;
63
92
  protected abstract createRetryAttemptImpl(failedStepId: string, status: 'pending' | 'running'): Promise<StepState>;
64
- abstract withRunLock<T>(id: string, fn: () => Promise<T>): Promise<T>;
65
- abstract withStepLock<T>(runId: string, stepName: string, fn: () => Promise<T>): Promise<T>;
93
+ /** One orchestration pass under the run's lease; never waits (see `holdWorkflowLease`). */
94
+ withRunLease<T>(id: string, fn: () => Promise<T>): Promise<T>;
95
+ /** `fn` under one step's lease; never waits (see `holdWorkflowLease`). */
96
+ withStepLock<T>(runId: string, stepName: string, fn: () => Promise<T>): Promise<T>;
66
97
  abstract close(): Promise<void>;
67
98
  abstract getCompletedGraphState(runId: string): Promise<{
68
99
  completedNodeIds: string[];
@@ -73,6 +104,8 @@ export declare abstract class PikkuWorkflowService implements WorkflowService {
73
104
  stepName: string;
74
105
  status: StepStatus;
75
106
  fromStepName?: string;
107
+ /** Set by a store that records step leases; see `runningStepLease`. */
108
+ leaseExpiresAt?: Date;
76
109
  }>>;
77
110
  abstract getNodeResults(runId: string, nodeIds: string[]): Promise<Record<string, any>>;
78
111
  setBranchTaken(stepId: string, branchKey: string): Promise<void>;
@@ -89,10 +122,15 @@ export declare abstract class PikkuWorkflowService implements WorkflowService {
89
122
  source: string;
90
123
  } | null>;
91
124
  resumeWorkflow(runId: string, workflowName?: string): Promise<void>;
125
+ private enqueueOrchestration;
92
126
  /**
93
127
  * Ids of runs that are stalled: still `running`, with no step in a state that
94
- * something is expected to complete (`running`, `scheduled`, `suspended`),
95
- * and no step activity since `before`.
128
+ * something is expected to complete (`scheduled`, `suspended`, or `running`
129
+ * under a live lease), and no step activity since `before`.
130
+ *
131
+ * A `running` step whose lease has lapsed is the opposite of in flight — the
132
+ * dispatch that claimed it is gone — so it does not hold its run out of the
133
+ * sweep.
96
134
  *
97
135
  * Returns nothing by default so a store that cannot express the query keeps
98
136
  * working unchanged; a store that overrides it gains crash recovery through
@@ -175,7 +213,6 @@ export declare abstract class PikkuWorkflowService implements WorkflowService {
175
213
  private runWorkflowJobInner;
176
214
  private onChildWorkflowCompleted;
177
215
  protected onChildWorkflowFailed(childRun: WorkflowRun, error: Error): Promise<void>;
178
- private runVersionMismatchFallback;
179
216
  executeWorkflowStep(runId: string, stepName: string, rpcName: string, data: any, rpcService: PikkuRPC): Promise<void>;
180
217
  /**
181
218
  * Take sole ownership of a step before it runs, returning the state to run
@@ -185,18 +222,20 @@ export declare abstract class PikkuWorkflowService implements WorkflowService {
185
222
  * believes were dropped, and a queue can redeliver a job it already handed
186
223
  * out. This is the one place that keeps a duplicate dispatch from becoming a
187
224
  * second execution of a side-effecting step, so it is only as strong as the
188
- * exclusion it is built on — and `withStepLock` excludes nothing unless the
189
- * store backs it with a real primitive. A store able to express the decision
190
- * as one conditional write should override this rather than reach for a lock,
191
- * which is what `@pikku/kysely` does with a status-guarded `UPDATE`.
225
+ * exclusion it is built on: here, the step's lease on the app's
226
+ * `leaseService`. A store able to express the decision as one conditional
227
+ * write should override this rather than reach for a lock, which is what
228
+ * `@pikku/kysely` does with a status-guarded `UPDATE`.
229
+ *
230
+ * Ownership is held for `leaseMs` rather than forever. A dispatch that
231
+ * dies mid-step leaves the step `running` with nothing to complete it, and an
232
+ * unconditional rejection of `running` would then wedge it beyond the reach
233
+ * of both this claim and the stalled-run sweep.
192
234
  */
193
- protected claimStepForExecution(runId: string, stepName: string, rpcName: string): Promise<StepState | null>;
235
+ protected claimStepForExecution(runId: string, stepName: string, rpcName: string, leaseMs: number): Promise<StepState | null>;
194
236
  private executeWorkflowStepInner;
195
237
  orchestrateWorkflow(runId: string, rpcService: PikkuRPC): Promise<void>;
196
- private verifyQueueService;
197
238
  private invokeStepRpc;
198
- private runInlineRetryLoop;
199
- private runStepCompensation;
200
239
  private rpcStep;
201
240
  protected inlineStep(runId: string, logicalStepName: string, fn: Function, stepOptions?: WorkflowStepOptions, data?: any, rpcName?: string | null): Promise<any>;
202
241
  private sleepStep;