-
Notifications
You must be signed in to change notification settings - Fork 336
[core] Add atomic start Hook admission #3426
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| --- | ||
| "workflow": minor | ||
| "@workflow/core": minor | ||
| "@workflow/errors": minor | ||
| "@workflow/world": minor | ||
| "@workflow/cli": patch | ||
| "@workflow/web-shared": patch | ||
| "@workflow/world-vercel": patch | ||
| --- | ||
|
|
||
| Add atomic workflow admission with `start({ hook })` and a typed error for uncertain start outcomes. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,13 +4,15 @@ import { | |
| CorruptedEventLogError, | ||
| EntityConflictError, | ||
| FatalError, | ||
| HookConflictError, | ||
| HookNotFoundError, | ||
| MaxEventsExceededError, | ||
| PreconditionFailedError, | ||
| ReplayDivergenceError, | ||
| RUN_ERROR_CODES, | ||
| type RunErrorCode, | ||
| RunExpiredError, | ||
| START_HOOK_ADMISSION_REJECTED, | ||
| WorkflowRuntimeError, | ||
| WorkflowWorldError, | ||
| } from '@workflow/errors'; | ||
|
|
@@ -28,6 +30,7 @@ import { | |
| isLegacySpecVersion, | ||
| isTerminalRunEventType, | ||
| ROOT_RUN_ID_ATTRIBUTE, | ||
| type RunCreationData, | ||
| type RunInput, | ||
| resolveQueueNamespace, | ||
| SPEC_VERSION_CURRENT, | ||
|
|
@@ -170,6 +173,7 @@ export { | |
| wakeUpRun, | ||
| } from './runtime/runs.js'; | ||
| export { | ||
| type StartHookOptions, | ||
| type StartOptions, | ||
| type StartOptionsBase, | ||
| type StartOptionsWithDeploymentId, | ||
|
|
@@ -1011,6 +1015,7 @@ export function workflowEntrypoint( | |
| const turbo = | ||
| isTurboEnabled() && | ||
| runInput !== undefined && | ||
| runInput.startHook === undefined && | ||
| metadata.attempt === 1 && | ||
| incomingStepId === undefined && | ||
| !replayDivergence; | ||
|
|
@@ -2049,6 +2054,15 @@ export function workflowEntrypoint( | |
| // Contract: events.create('run_started') must be idempotent | ||
| // for runs already in 'running' status (return the run | ||
| // without error), not just for pending → running transitions. | ||
| let runCreationData: RunCreationData | undefined; | ||
| if (runInput) { | ||
| const { | ||
| environment: _environment, | ||
| specVersion: _specVersion, | ||
| ...data | ||
| } = runInput; | ||
| runCreationData = data; | ||
| } | ||
| const runStartedEvent = { | ||
| eventType: 'run_started' as const, | ||
| // Use the spec version from the original start() call | ||
|
|
@@ -2060,18 +2074,8 @@ export function workflowEntrypoint( | |
| // create the run if run_created was missed. | ||
| // Uint8Array values survive the queue natively | ||
| // (CBOR on world-vercel, JSON reviver on world-local). | ||
| ...(runInput | ||
| ? { | ||
| eventData: { | ||
| input: runInput.input, | ||
| deploymentId: runInput.deploymentId, | ||
| workflowName: runInput.workflowName, | ||
| executionContext: runInput.executionContext, | ||
| attributes: runInput.attributes, | ||
| allowReservedAttributes: | ||
| runInput.allowReservedAttributes, | ||
| }, | ||
| } | ||
| ...(runCreationData | ||
| ? { eventData: runCreationData } | ||
| : {}), | ||
| }; | ||
| if (turbo && runInput) { | ||
|
|
@@ -2204,6 +2208,27 @@ export function workflowEntrypoint( | |
| return; | ||
| } | ||
| } catch (err) { | ||
| if (runInput?.startHook !== undefined) { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. AI Review: BlockingThis prelude sits before the For a world-contract error the pre-existing path writes I confirmed this against the A second scratch test showed The second branch is strictly worse than falling through for contract errors, since the fall-through already stops the retry loop and records the failure. Narrowing the swallow to |
||
| if (HookConflictError.is(err)) { | ||
| return; | ||
| } | ||
| if ( | ||
| WorkflowWorldError.is(err) && | ||
| err.code === START_HOOK_ADMISSION_REJECTED | ||
| ) { | ||
| runtimeLogger.error( | ||
| 'Atomic start Hook admission rejected queued candidate', | ||
| { | ||
| workflowRunId: runId, | ||
| error: | ||
| err instanceof Error | ||
| ? err.message | ||
| : String(err), | ||
| } | ||
| ); | ||
| return; | ||
| } | ||
| } | ||
| // Run was concurrently completed/failed/cancelled | ||
| if ( | ||
| EntityConflictError.is(err) || | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
AI Review: Note
Behavior change that applies to every run, not just atomic-start ones.
Replacing the explicit six-field pick with
{...runInput}minusenvironment/specVersionmeansrun_startednow also carriesencryptionPublicKeyon the resilient-start path. Verified empirically by porting the new assertion on line 2348 back to the base branch:This looks like a fix, and it matches what the (now-deleted)
RunStartedEventSchemacomment described: on the resilient path the run is created from this event, and without the key it silently loses the ability to receive sealed writes. But it ships unannounced — the changeset doesn't mention it, and the only coverage is an added assertion inside a test named for turbo optimistic start. Worth its own changeset line and a test that names the behavior, so a future refactor doesn't drop it again.The spread also means any field added to
RunInputlater is auto-forwarded intorun_startedrather than opted in. That's the mechanism that just quietly changed the payload here.