@kb-labs/workflow-engine 2.118.2 → 2.119.0-canary.2077501f1

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/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { WorkflowSpec, RunTrigger, IdempotencyKey, ConcurrencyGroup, WorkflowRun, JobRun, StepRun, RetryPolicy, ArtifactMergeConfig } from '@kb-labs/workflow-contracts';
2
2
  import { ILogger, ICache, IEventBus, IAnalytics, ISnapshotManager, Unsubscribe, IJobScheduler, JobDefinition, JobHandle, CronExpression, JobFilter } from '@kb-labs/core-platform';
3
- import { JobPriority, WorkflowEventName } from '@kb-labs/workflow-constants';
3
+ import { AssertTransitionOptions, JobPriority, WorkflowEventName } from '@kb-labs/workflow-constants';
4
4
  import { ClassifiedFailure, IExecutionBackend } from '@kb-labs/core-contracts';
5
5
  import { ArtifactClient } from '@kb-labs/workflow-artifacts';
6
6
  import { IEntityRegistry } from '@kb-labs/core-registry';
@@ -51,9 +51,34 @@ declare class StateStore {
51
51
  getRun(runId: string): Promise<WorkflowRun | null>;
52
52
  deleteRun(runId: string): Promise<void>;
53
53
  getAllRunIds(): Promise<string[]>;
54
+ /**
55
+ * Holds an exclusive per-run lock (see `withLock`) for the whole
56
+ * read-modify-write, so `mutator` runs exactly once per call — no other
57
+ * writer can observe or clobber the run in between. This is what actually
58
+ * fixes the original bug class (a job marked `failed` while its step was
59
+ * mid-write to `waiting_approval`): the two writes can no longer interleave.
60
+ *
61
+ * `mutator` must still not `await` — it runs synchronously against a
62
+ * single in-memory draft while the lock is held; async work belongs after
63
+ * `updateRun` resolves (and holding the lock across an `await` would just
64
+ * make every other writer to this run block on it needlessly).
65
+ */
54
66
  updateRun(runId: string, mutator: (draft: WorkflowRun) => WorkflowRun | void): Promise<WorkflowRun | null>;
55
67
  updateJob(runId: string, jobId: string, mutator: (job: JobRun) => JobRun | void): Promise<JobRun | null>;
56
68
  updateStep(runId: string, jobId: string, stepId: string, mutator: (step: StepRun) => StepRun | void): Promise<StepRun | null>;
69
+ /**
70
+ * Like `updateRun`, but validates the status transition against the
71
+ * workflow state machine before applying it — throws
72
+ * `IllegalStateTransitionError` (from `@kb-labs/workflow-constants`) if
73
+ * `to` is not reachable from the run's current status. `mutate` sets any
74
+ * *other* fields; it must not itself assign `.status` (this method owns
75
+ * that assignment, after the check).
76
+ */
77
+ transitionRun(runId: string, to: WorkflowRun['status'], mutate?: (draft: WorkflowRun) => void, options?: AssertTransitionOptions): Promise<WorkflowRun | null>;
78
+ /** Job-level counterpart of `transitionRun` — see its docblock. */
79
+ transitionJob(runId: string, jobId: string, to: JobRun['status'], mutate?: (draft: JobRun) => void, options?: AssertTransitionOptions): Promise<JobRun | null>;
80
+ /** Step-level counterpart of `transitionRun` — see its docblock. */
81
+ transitionStep(runId: string, jobId: string, stepId: string, to: StepRun['status'], mutate?: (draft: StepRun) => void, options?: AssertTransitionOptions): Promise<StepRun | null>;
57
82
  releaseBlockedJobs(runId: string, completedJobName: string): Promise<JobRun[]>;
58
83
  }
59
84
 
@@ -111,6 +136,16 @@ declare class Scheduler {
111
136
  enqueueJob(runId: string, job: JobRun, priority?: JobPriority): Promise<void>;
112
137
  dequeueJob(): Promise<JobQueueEntry | null>;
113
138
  reschedule(entry: JobQueueEntry, delayMs: number): Promise<void>;
139
+ /**
140
+ * The read (`zrangebyscore`) and the remove (`zrem`) below are two
141
+ * separate cache round-trips, not one atomic op — without the lock, two
142
+ * daemon instances racing this method could both read the same top entry
143
+ * before either removes it, and both would go on to execute the same job
144
+ * (the daemon runs multiple instances in production, so this is a live
145
+ * bug, not a theoretical one). `withLock` serializes dequeues against this
146
+ * one priority queue across every process sharing the same cache backend,
147
+ * the same way `StateStore.updateRun` serializes writes to one run.
148
+ */
114
149
  private dequeueFromPriority;
115
150
  getDefaultPriority(): JobPriority;
116
151
  }
@@ -293,10 +328,26 @@ declare class WorkflowEngine {
293
328
  /**
294
329
  * Mark step as waiting for human approval.
295
330
  */
331
+ /**
332
+ * Park a step waiting for human approval — and park its parent job with
333
+ * it, in the SAME atomic write (one `transitionJob` call touching both the
334
+ * job's own status and its nested step). Two separate writes (step then
335
+ * job) would leave a window where a reader could observe step=waiting but
336
+ * job=running; going through one call closes that window entirely, not
337
+ * just narrows it.
338
+ *
339
+ * The job-level `waiting_approval` status is what makes the daemon-restart
340
+ * exemption in `cleanupStaleRuns` structural: that force-fail loop only
341
+ * ever touches `running`/`queued` jobs, so a parked job is never in its
342
+ * blast radius — no bespoke "is this job actually abandoned or just
343
+ * waiting on a human" check needed there.
344
+ */
296
345
  markStepWaitingApproval(runId: string, jobId: string, stepId: string): Promise<void>;
297
346
  /**
298
- * Park a step while its child workflow runs. The worker returns after this
299
- * transition, so parent workflows never consume the pool needed by children.
347
+ * Park a step (and its parent job see `markStepWaitingApproval`'s
348
+ * docblock for why job+step move together in one write) while its child
349
+ * workflow runs. The worker returns after this transition, so parent
350
+ * workflows never consume the pool needed by children.
300
351
  */
301
352
  markStepWaitingChild(runId: string, jobId: string, stepId: string, childRunId: string): Promise<void>;
302
353
  /** Re-queue a parked parent job after its child workflow reaches a terminal state. */
@@ -305,6 +356,12 @@ declare class WorkflowEngine {
305
356
  * Resolve a pending approval — approve or reject.
306
357
  * On approve: marks step as success with approval outputs.
307
358
  * On reject: marks step as failed with rejection error.
359
+ *
360
+ * Resolves the step AND un-parks the job (`waiting_approval` → `queued`)
361
+ * in one atomic write — same reasoning as `markStepWaitingApproval` — then
362
+ * re-enqueues the job. This re-enqueue is what actually resumes execution:
363
+ * with approval no longer polled in-process (the worker parks and returns
364
+ * instead of waiting), nothing else will ever pick this job back up.
308
365
  */
309
366
  resolveApproval(runId: string, jobId: string, stepId: string, action: 'approve' | 'reject', data?: Record<string, unknown>, comment?: string): Promise<void>;
310
367
  /**
@@ -316,9 +373,12 @@ declare class WorkflowEngine {
316
373
  */
317
374
  getScheduler(): Scheduler;
318
375
  /**
319
- * Mark stale running/queued runs as failed on daemon startup.
320
- * Runs that were in-flight when the daemon crashed are unrecoverable
321
- * their executor process is gone, so we mark them failed immediately.
376
+ * Mark stale running/queued jobs as failed on daemon startup — their
377
+ * executor process is gone, so they're unrecoverable. The run itself is
378
+ * only finalized as 'failed' if nothing else could still complete it;
379
+ * a run with one abandoned job and one job legitimately parked on a human
380
+ * approval or a child workflow stays 'running' (only the abandoned job is
381
+ * failed) until the parked one resolves.
322
382
  */
323
383
  cleanupStaleRuns(): Promise<void>;
324
384
  /**