From cd352305927fbe051f2205283f684983a185f7fb Mon Sep 17 00:00:00 2001 From: wangbill Date: Wed, 5 Aug 2026 09:59:07 -0700 Subject: [PATCH 1/5] feat: support versioned continue-as-new Allow orchestrators to select the version for their next generation while preserving existing continueAsNew calls. Propagate the value through protocol actions and the in-memory backend. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 2 ++ README.md | 19 +++++++++++++ packages/azure-functions-durable/CHANGELOG.md | 2 ++ packages/azure-functions-durable/README.md | 3 +++ .../src/orchestration-context.ts | 10 ++++--- .../test/unit/orchestration-context.spec.ts | 9 +++++++ .../src/task/context/orchestration-context.ts | 3 ++- .../src/testing/in-memory-backend.ts | 10 ++++++- .../src/utils/pb-helper.util.ts | 13 +++++++-- .../worker/runtime-orchestration-context.ts | 13 ++++++--- .../test/in-memory-backend.spec.ts | 27 +++++++++++++++++++ .../test/orchestration_executor.spec.ts | 24 +++++++++++++++++ 12 files changed, 124 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e2f8a3e8..333929cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ### New +- Add an optional `newVersion` parameter to `OrchestrationContext.continueAsNew()` for version migrations. + ### Fixes diff --git a/README.md b/README.md index 683638b2..42d5d432 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,25 @@ const purchaseOrderWorkflow: TOrchestrator = async function* ( You can find the full sample at [examples/hello-world/human_interaction.ts](./examples/hello-world/human_interaction.ts). +### Continue as new + +Long-running orchestrations can restart with fresh history and optionally move to a new +orchestration version: + +```typescript +const eternalOrchestrator: TOrchestrator = async function* ( + ctx: OrchestrationContext, + iteration: number, +): any { + yield ctx.callActivity(processIteration, iteration); + ctx.continueAsNew(iteration + 1, true, "2.0.0"); +}; +``` + +The second argument controls whether unprocessed external events carry over. The optional third +argument becomes the restarted orchestration's `ctx.version`; omit it to retain the existing +continue-as-new behavior. + ### Durable entities Durable entities provide a way to manage small pieces of state with a simple object-oriented programming model: diff --git a/packages/azure-functions-durable/CHANGELOG.md b/packages/azure-functions-durable/CHANGELOG.md index f2fc94e0..1b1f5ecd 100644 --- a/packages/azure-functions-durable/CHANGELOG.md +++ b/packages/azure-functions-durable/CHANGELOG.md @@ -2,6 +2,8 @@ ### New +- Add optional orchestration version migration support to `context.df.continueAsNew()`. + ### Fixes diff --git a/packages/azure-functions-durable/README.md b/packages/azure-functions-durable/README.md index 801cf015..ef5dd92e 100644 --- a/packages/azure-functions-durable/README.md +++ b/packages/azure-functions-durable/README.md @@ -38,6 +38,9 @@ changed: `showHistory` populates `history`, and `showHistoryOutput` toggles the per-entry input/result payloads; `history` entries are core `HistoryEvent`s (v3 types `history` as `Array`). **`client.startNew()` supports the `version` option.** +- **`context.df.continueAsNew(input, saveEvents, newVersion)` can migrate versions.** The optional + third argument assigns the restarted orchestration's version; existing one- and two-argument + calls keep their current behavior. - **Entity locking / critical sections moved to the core context.** v3's `context.df.lock(...)` / `context.df.isLocked()` and the `DurableLock` / `LockState` / `LockingRulesViolationError` exports are removed. Locks live on the core-native `context.entities` surface, which the classic diff --git a/packages/azure-functions-durable/src/orchestration-context.ts b/packages/azure-functions-durable/src/orchestration-context.ts index b4102e3b..4349d521 100644 --- a/packages/azure-functions-durable/src/orchestration-context.ts +++ b/packages/azure-functions-durable/src/orchestration-context.ts @@ -127,9 +127,13 @@ export class DurableOrchestrationContext { return this._ctx.waitForExternalEvent(name) as Task; } - /** Restarts the orchestration with a new input. */ - continueAsNew(input: unknown, saveEvents = true): void { - this._ctx.continueAsNew(input, saveEvents); + /** Restarts the orchestration with a new input and optionally a new version. */ + continueAsNew(input: unknown, saveEvents = true, newVersion?: string): void { + if (newVersion === undefined) { + this._ctx.continueAsNew(input, saveEvents); + } else { + this._ctx.continueAsNew(input, saveEvents, newVersion); + } } /** Sets the orchestration's custom status payload. */ diff --git a/packages/azure-functions-durable/test/unit/orchestration-context.spec.ts b/packages/azure-functions-durable/test/unit/orchestration-context.spec.ts index eb8f7e2d..2f9f7289 100644 --- a/packages/azure-functions-durable/test/unit/orchestration-context.spec.ts +++ b/packages/azure-functions-durable/test/unit/orchestration-context.spec.ts @@ -151,6 +151,15 @@ describe("DurableOrchestrationContext", () => { expect(entities.signalEntity).toHaveBeenCalledWith(entityId, "reset", undefined); }); + it("forwards a new version when continuing as new", () => { + const { ctx, raw } = createFakeCoreContext(); + const df = new DurableOrchestrationContext(ctx, undefined); + + df.continueAsNew("next", false, "2.0.0"); + + expect(raw.continueAsNew).toHaveBeenCalledWith("next", false, "2.0.0"); + }); + it("schedules callHttp as the built-in poll sub-orchestration with the built request payload", () => { const { ctx, raw } = createFakeCoreContext(); const df = new DurableOrchestrationContext(ctx, undefined); diff --git a/packages/durabletask-js/src/task/context/orchestration-context.ts b/packages/durabletask-js/src/task/context/orchestration-context.ts index bf75699f..3847a0d7 100644 --- a/packages/durabletask-js/src/task/context/orchestration-context.ts +++ b/packages/durabletask-js/src/task/context/orchestration-context.ts @@ -163,8 +163,9 @@ export abstract class OrchestrationContext { * * @param newInput {any} The new input to use for the new orchestration instance. * @param saveEvents {boolean} A flag indicating whether to add any unprocessed external events in the new orchestration history. + * @param newVersion {string} The optional version to use for the new orchestration instance. */ - abstract continueAsNew(newInput: any, saveEvents: boolean): void; + abstract continueAsNew(newInput: any, saveEvents: boolean, newVersion?: string): void; /** * Sets a custom status value for the current orchestration instance. diff --git a/packages/durabletask-js/src/testing/in-memory-backend.ts b/packages/durabletask-js/src/testing/in-memory-backend.ts index 1275aace..0bbce544 100644 --- a/packages/durabletask-js/src/testing/in-memory-backend.ts +++ b/packages/durabletask-js/src/testing/in-memory-backend.ts @@ -594,6 +594,7 @@ export class InMemoryOrchestrationBackend { if (status === pb.OrchestrationStatus.ORCHESTRATION_STATUS_CONTINUED_AS_NEW) { // Handle continue-as-new const newInput = completeAction.getResult()?.getValue(); + const newVersion = completeAction.getNewversion()?.getValue(); const carryoverEvents = completeAction.getCarryovereventsList(); // Cancel timers still pending from the previous iteration. Their timer IDs are @@ -623,7 +624,14 @@ export class InMemoryOrchestrationBackend { // because it sets currentUtcDateTime, and ExecutionStarted must come before // carryover events because it initializes the orchestrator generator. const orchestratorStarted = pbh.newOrchestratorStartedEvent(new Date()); - const executionStarted = pbh.newExecutionStartedEvent(instance.name, instance.instanceId, newInput, undefined, instance.executionId); + const executionStarted = pbh.newExecutionStartedEvent( + instance.name, + instance.instanceId, + newInput, + undefined, + instance.executionId, + newVersion, + ); instance.pendingEvents = [orchestratorStarted, executionStarted, ...carryoverEvents]; this.enqueueOrchestration(instance.instanceId); diff --git a/packages/durabletask-js/src/utils/pb-helper.util.ts b/packages/durabletask-js/src/utils/pb-helper.util.ts index 9f353798..227ea686 100644 --- a/packages/durabletask-js/src/utils/pb-helper.util.ts +++ b/packages/durabletask-js/src/utils/pb-helper.util.ts @@ -22,7 +22,14 @@ export function newOrchestratorStartedEvent(timestamp?: Date | null): pb.History return event; } -export function newExecutionStartedEvent(name: string, instanceId: string, encodedInput?: string, parentInstance?: { name: string; instanceId: string; taskScheduledId: number }, executionId?: string): pb.HistoryEvent { +export function newExecutionStartedEvent( + name: string, + instanceId: string, + encodedInput?: string, + parentInstance?: { name: string; instanceId: string; taskScheduledId: number }, + executionId?: string, + version?: string, +): pb.HistoryEvent { const ts = new Timestamp(); const orchestrationInstance = new pb.OrchestrationInstance(); @@ -39,6 +46,7 @@ export function newExecutionStartedEvent(name: string, instanceId: string, encod executionStartedEvent.setName(name); executionStartedEvent.setInput(getStringValue(encodedInput)); executionStartedEvent.setOrchestrationinstance(orchestrationInstance); + executionStartedEvent.setVersion(getStringValue(version)); // Set parent instance info if provided (for sub-orchestrations) if (parentInstance) { @@ -390,12 +398,14 @@ export function newCompleteOrchestrationAction( result?: string, failureDetails?: pb.TaskFailureDetails, carryoverEvents?: pb.HistoryEvent[] | null, + newVersion?: string, ): pb.OrchestratorAction { const completeOrchestrationAction = new pb.CompleteOrchestrationAction(); completeOrchestrationAction.setOrchestrationstatus(status); completeOrchestrationAction.setResult(getStringValue(result)); completeOrchestrationAction.setFailuredetails(failureDetails); completeOrchestrationAction.setCarryovereventsList(carryoverEvents || []); + completeOrchestrationAction.setNewversion(getStringValue(newVersion)); const action = new pb.OrchestratorAction(); action.setId(id); @@ -679,4 +689,3 @@ function wrapEntityMessageAction( action.setSendentitymessage(sendEntityMessage); return action; } - diff --git a/packages/durabletask-js/src/worker/runtime-orchestration-context.ts b/packages/durabletask-js/src/worker/runtime-orchestration-context.ts index 828deb4e..39caea41 100644 --- a/packages/durabletask-js/src/worker/runtime-orchestration-context.ts +++ b/packages/durabletask-js/src/worker/runtime-orchestration-context.ts @@ -49,6 +49,7 @@ export class RuntimeOrchestrationContext extends OrchestrationContext { _pendingEvents: Record[]>; _newInput?: any; _saveEvents: boolean; + _newVersion?: string; _customStatus?: string; _entityFeature: RuntimeOrchestrationEntityFeature; @@ -72,6 +73,7 @@ export class RuntimeOrchestrationContext extends OrchestrationContext { this._pendingEvents = {}; this._newInput = undefined; this._saveEvents = false; + this._newVersion = undefined; this._customStatus = undefined; this._entityFeature = new RuntimeOrchestrationEntityFeature(this); } @@ -258,7 +260,7 @@ export class RuntimeOrchestrationContext extends OrchestrationContext { this._pendingActions[action.getId()] = action; } - setContinuedAsNew(newInput: any, saveEvents: boolean) { + setContinuedAsNew(newInput: any, saveEvents: boolean, newVersion?: string) { if (this._isComplete) { return; } @@ -267,6 +269,7 @@ export class RuntimeOrchestrationContext extends OrchestrationContext { this._completionStatus = pb.OrchestrationStatus.ORCHESTRATION_STATUS_CONTINUED_AS_NEW; this._newInput = newInput; this._saveEvents = saveEvents; + this._newVersion = newVersion; } getActions(): pb.OrchestratorAction[] { @@ -292,6 +295,7 @@ export class RuntimeOrchestrationContext extends OrchestrationContext { this._newInput !== undefined ? JSON.stringify(this._newInput) : undefined, undefined, carryoverEvents, + this._newVersion, ); // Include fire-and-forget actions (sendEvent, signalEntity, etc.) that were @@ -459,14 +463,15 @@ export class RuntimeOrchestrationContext extends OrchestrationContext { } /** - * Orchestrations can be continued as new. This API allows an orchestration to restart itself from scratch, optionally with a new input. + * Restarts the orchestration with fresh history, optionally carrying over unprocessed events + * and assigning a new orchestration version. */ - continueAsNew(newInput: any, saveEvents: boolean = false) { + continueAsNew(newInput: any, saveEvents: boolean = false, newVersion?: string) { if (this._isComplete) { return; } - this.setContinuedAsNew(newInput, saveEvents); + this.setContinuedAsNew(newInput, saveEvents, newVersion); } /** diff --git a/packages/durabletask-js/test/in-memory-backend.spec.ts b/packages/durabletask-js/test/in-memory-backend.spec.ts index 647845ec..630c0833 100644 --- a/packages/durabletask-js/test/in-memory-backend.spec.ts +++ b/packages/durabletask-js/test/in-memory-backend.spec.ts @@ -331,6 +331,33 @@ describe("In-Memory Backend", () => { expect(state?.serializedOutput).toEqual(JSON.stringify(5)); }); + it("should use the requested version after continue-as-new", async () => { + const observedVersions: string[] = []; + const orchestrator: TOrchestrator = async (ctx: OrchestrationContext, input: number) => { + if (!ctx.isReplaying) { + observedVersions.push(ctx.version); + } + + if (input === 0) { + ctx.continueAsNew(1, false, "2.0.0"); + return; + } + + return ctx.version; + }; + + worker.addOrchestrator(orchestrator); + await worker.start(); + + const id = await client.scheduleNewOrchestration(orchestrator, 0); + const state = await client.waitForOrchestrationCompletion(id, true, 10); + + expect(state).toBeDefined(); + expect(state?.runtimeStatus).toEqual(OrchestrationStatus.COMPLETED); + expect(state?.serializedOutput).toEqual(JSON.stringify("2.0.0")); + expect(observedVersions).toEqual(["", "2.0.0"]); + }); + it("should not collide default sub-orchestration instance IDs across continue-as-new generations", async () => { // Regression for the callHttp-on-continueAsNew collision: a default (auto-derived) child // instance ID must be unique per generation. Before the fix the derived ID was diff --git a/packages/durabletask-js/test/orchestration_executor.spec.ts b/packages/durabletask-js/test/orchestration_executor.spec.ts index 94a9780a..82f720a1 100644 --- a/packages/durabletask-js/test/orchestration_executor.spec.ts +++ b/packages/durabletask-js/test/orchestration_executor.spec.ts @@ -1010,6 +1010,7 @@ describe("Orchestration Executor", () => { ); expect(completeAction?.getResult()?.getValue()).toEqual(JSON.stringify(2)); expect(completeAction?.getCarryovereventsList()?.length).toEqual(saveEvent ? 3 : 0); + expect(completeAction?.getNewversion()).toBeUndefined(); for (let i = 0; i < (completeAction?.getCarryovereventsList()?.length ?? 0); i++) { const event = completeAction?.getCarryovereventsList()[i]; @@ -1024,6 +1025,29 @@ describe("Orchestration Executor", () => { } }); + it("should set the new version on a continue-as-new action", async () => { + const orchestrator: TOrchestrator = async (ctx: OrchestrationContext, input: number) => { + ctx.continueAsNew(input + 1, false, "2.0.0"); + }; + + const registry = new Registry(); + const orchestratorName = registry.addOrchestrator(orchestrator); + const newEvents = [ + newOrchestratorStartedEvent(), + newExecutionStartedEvent(orchestratorName, TEST_INSTANCE_ID, "1"), + ]; + + const executor = new OrchestrationExecutor(registry, testLogger); + const result = await executor.execute(TEST_INSTANCE_ID, [], newEvents); + + const completeAction = getAndValidateSingleCompleteOrchestrationAction(result); + expect(completeAction?.getOrchestrationstatus()).toEqual( + pb.OrchestrationStatus.ORCHESTRATION_STATUS_CONTINUED_AS_NEW, + ); + expect(completeAction?.getResult()?.getValue()).toEqual(JSON.stringify(2)); + expect(completeAction?.getNewversion()?.getValue()).toEqual("2.0.0"); + }); + it("should test that a fan-out pattern correctly schedules N tasks", async () => { const hello = async (_: any, name: string) => { return `Hello ${name}`; From fd7dffe55d1a65de97ac523d06ac7d26d2c7bfd5 Mon Sep 17 00:00:00 2001 From: wangbill Date: Wed, 5 Aug 2026 10:22:38 -0700 Subject: [PATCH 2/5] fix: retain version across continue-as-new Preserve the current execution version when a continue-as-new action does not explicitly select a replacement version. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a7732f13-7795-47cc-912c-71f834df686e --- .../src/testing/in-memory-backend.ts | 9 ++++++++- .../test/in-memory-backend.spec.ts | 20 ++++++++++++++++--- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/packages/durabletask-js/src/testing/in-memory-backend.ts b/packages/durabletask-js/src/testing/in-memory-backend.ts index 0bbce544..bfd9b544 100644 --- a/packages/durabletask-js/src/testing/in-memory-backend.ts +++ b/packages/durabletask-js/src/testing/in-memory-backend.ts @@ -594,7 +594,14 @@ export class InMemoryOrchestrationBackend { if (status === pb.OrchestrationStatus.ORCHESTRATION_STATUS_CONTINUED_AS_NEW) { // Handle continue-as-new const newInput = completeAction.getResult()?.getValue(); - const newVersion = completeAction.getNewversion()?.getValue(); + const currentVersion = instance.history + .find((event) => event.hasExecutionstarted()) + ?.getExecutionstarted() + ?.getVersion() + ?.getValue(); + const newVersion = completeAction.hasNewversion() + ? completeAction.getNewversion()?.getValue() + : currentVersion; const carryoverEvents = completeAction.getCarryovereventsList(); // Cancel timers still pending from the previous iteration. Their timer IDs are diff --git a/packages/durabletask-js/test/in-memory-backend.spec.ts b/packages/durabletask-js/test/in-memory-backend.spec.ts index 630c0833..55a75305 100644 --- a/packages/durabletask-js/test/in-memory-backend.spec.ts +++ b/packages/durabletask-js/test/in-memory-backend.spec.ts @@ -14,6 +14,7 @@ import { TOrchestrator, } from "../src"; import * as pb from "../src/proto/orchestrator_service_pb"; +import { StringValue } from "google-protobuf/google/protobuf/wrappers_pb"; describe("In-Memory Backend", () => { let backend: InMemoryOrchestrationBackend; @@ -331,7 +332,7 @@ describe("In-Memory Backend", () => { expect(state?.serializedOutput).toEqual(JSON.stringify(5)); }); - it("should use the requested version after continue-as-new", async () => { + it("should retain the current version when continue-as-new omits a new version", async () => { const observedVersions: string[] = []; const orchestrator: TOrchestrator = async (ctx: OrchestrationContext, input: number) => { if (!ctx.isReplaying) { @@ -343,19 +344,32 @@ describe("In-Memory Backend", () => { return; } + if (input === 1) { + ctx.continueAsNew(2, false); + return; + } + return ctx.version; }; worker.addOrchestrator(orchestrator); + const id = "versioned-continue-as-new"; + backend.createInstance(id, getName(orchestrator), JSON.stringify(0)); + const initialExecutionStarted = backend + .getInstance(id) + ?.pendingEvents.find((event) => event.hasExecutionstarted()) + ?.getExecutionstarted(); + const initialVersion = new StringValue(); + initialVersion.setValue("1.0.0"); + initialExecutionStarted?.setVersion(initialVersion); await worker.start(); - const id = await client.scheduleNewOrchestration(orchestrator, 0); const state = await client.waitForOrchestrationCompletion(id, true, 10); expect(state).toBeDefined(); expect(state?.runtimeStatus).toEqual(OrchestrationStatus.COMPLETED); expect(state?.serializedOutput).toEqual(JSON.stringify("2.0.0")); - expect(observedVersions).toEqual(["", "2.0.0"]); + expect(observedVersions).toEqual(["1.0.0", "2.0.0", "2.0.0"]); }); it("should not collide default sub-orchestration instance IDs across continue-as-new generations", async () => { From e062da53258a34e461659be63041876628bc7764 Mon Sep 17 00:00:00 2001 From: wangbill Date: Wed, 5 Aug 2026 10:36:37 -0700 Subject: [PATCH 3/5] fix: preserve empty continue-as-new version Serialize an explicitly empty new version as a present protobuf wrapper so it clears the current orchestration version instead of retaining it. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a7732f13-7795-47cc-912c-71f834df686e --- .../src/utils/pb-helper.util.ts | 14 ++++++-- .../test/in-memory-backend.spec.ts | 35 +++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/packages/durabletask-js/src/utils/pb-helper.util.ts b/packages/durabletask-js/src/utils/pb-helper.util.ts index 227ea686..45e500cb 100644 --- a/packages/durabletask-js/src/utils/pb-helper.util.ts +++ b/packages/durabletask-js/src/utils/pb-helper.util.ts @@ -46,7 +46,7 @@ export function newExecutionStartedEvent( executionStartedEvent.setName(name); executionStartedEvent.setInput(getStringValue(encodedInput)); executionStartedEvent.setOrchestrationinstance(orchestrationInstance); - executionStartedEvent.setVersion(getStringValue(version)); + executionStartedEvent.setVersion(getStringValueIfDefined(version)); // Set parent instance info if provided (for sub-orchestrations) if (parentInstance) { @@ -368,6 +368,16 @@ export function getStringValue(val?: string): StringValue | undefined { return stringValue; } +function getStringValueIfDefined(val?: string): StringValue | undefined { + if (val === undefined) { + return; + } + + const stringValue = new StringValue(); + stringValue.setValue(val); + return stringValue; +} + /** * Populates a tag map with the provided tags. * @@ -405,7 +415,7 @@ export function newCompleteOrchestrationAction( completeOrchestrationAction.setResult(getStringValue(result)); completeOrchestrationAction.setFailuredetails(failureDetails); completeOrchestrationAction.setCarryovereventsList(carryoverEvents || []); - completeOrchestrationAction.setNewversion(getStringValue(newVersion)); + completeOrchestrationAction.setNewversion(getStringValueIfDefined(newVersion)); const action = new pb.OrchestratorAction(); action.setId(id); diff --git a/packages/durabletask-js/test/in-memory-backend.spec.ts b/packages/durabletask-js/test/in-memory-backend.spec.ts index 55a75305..0c9f11ac 100644 --- a/packages/durabletask-js/test/in-memory-backend.spec.ts +++ b/packages/durabletask-js/test/in-memory-backend.spec.ts @@ -372,6 +372,41 @@ describe("In-Memory Backend", () => { expect(observedVersions).toEqual(["1.0.0", "2.0.0", "2.0.0"]); }); + it("should clear the current version when continue-as-new specifies an empty version", async () => { + const observedVersions: string[] = []; + const orchestrator: TOrchestrator = async (ctx: OrchestrationContext, input: number) => { + if (!ctx.isReplaying) { + observedVersions.push(ctx.version); + } + + if (input === 0) { + ctx.continueAsNew(1, false, ""); + return; + } + + return ctx.version; + }; + + worker.addOrchestrator(orchestrator); + const id = "clear-version-continue-as-new"; + backend.createInstance(id, getName(orchestrator), JSON.stringify(0)); + const initialExecutionStarted = backend + .getInstance(id) + ?.pendingEvents.find((event) => event.hasExecutionstarted()) + ?.getExecutionstarted(); + const initialVersion = new StringValue(); + initialVersion.setValue("2.0.0"); + initialExecutionStarted?.setVersion(initialVersion); + await worker.start(); + + const state = await client.waitForOrchestrationCompletion(id, true, 10); + + expect(state).toBeDefined(); + expect(state?.runtimeStatus).toEqual(OrchestrationStatus.COMPLETED); + expect(state?.serializedOutput).toEqual(JSON.stringify("")); + expect(observedVersions).toEqual(["2.0.0", ""]); + }); + it("should not collide default sub-orchestration instance IDs across continue-as-new generations", async () => { // Regression for the callHttp-on-continueAsNew collision: a default (auto-derived) child // instance ID must be unique per generation. Before the fix the derived ID was From 1cd84c6aaa44bceaaf315584916821c088c9e98b Mon Sep 17 00:00:00 2001 From: wangbill Date: Mon, 10 Aug 2026 09:04:38 -0700 Subject: [PATCH 4/5] ci: pin azure-functions-core-tools to 4.12.1 to fix broken upstream release The Functions Host E2E job failed for every PR at the "Install Azurite and Azure Functions Core Tools" step. The floating `azure-functions-core-tools@4` range resolved to the newest 4.x publish, 4.13.2, whose postinstall (lib/install.js) downloads a platform zip from cdn.functions.azure.com and calls process.exit(1) on any non-200 response. That zip is missing upstream: https://cdn.functions.azure.com/public/4.0.296705/Azure.Functions.Cli.linux-x64.4.13.2.zip -> HTTP 404 The previous release is intact: https://cdn.functions.azure.com/public/4.0.286861/Azure.Functions.Cli.linux-x64.4.12.1.zip -> HTTP 200 The 404 is deterministic, so re-running the job does not help. Pin the exact known-good version instead of the floating range so an unrelated bad upstream publish cannot break CI. The install step is deliberately left as a hard failure (no continue-on-error / retries): the E2E specs self-skip when `func` is absent, so swallowing an install failure would turn the gate into a silently-green no-op. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/functions-e2e-tests.yaml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/functions-e2e-tests.yaml b/.github/workflows/functions-e2e-tests.yaml index 7f62603c..2f23cc22 100644 --- a/.github/workflows/functions-e2e-tests.yaml +++ b/.github/workflows/functions-e2e-tests.yaml @@ -64,8 +64,14 @@ jobs: - name: "🏗️ Build in-repo durable-functions (+ core) for file: linking" run: npm run build -w durable-functions + # Core Tools is pinned to an exact version rather than the floating `@4` + # range: its postinstall downloads a platform zip from cdn.functions.azure.com + # and hard-fails (process.exit(1)) on any non-200, so a bad upstream publish + # breaks this job for every PR. `@4` floated onto 4.13.2, whose linux-x64 zip + # 404s on the CDN. Bump this pin deliberately once a newer version is + # confirmed to install. - name: 🔧 Install Azurite and Azure Functions Core Tools - run: npm install -g azurite azure-functions-core-tools@4 + run: npm install -g azurite azure-functions-core-tools@4.12.1 # --skipApiVersionCheck: the preview extension bundle's Azure Storage SDK # targets a newer REST API version than current Azurite accepts; without the From 8d80a569039ce54c201f5db7e62febdd02dca917 Mon Sep 17 00:00:00 2001 From: wangbill Date: Mon, 10 Aug 2026 09:11:39 -0700 Subject: [PATCH 5/5] fix: avoid leaking the ES2022 `ErrorOptions` type into the public .d.ts `OrchestrationAlreadyExistsError`'s constructor was typed with the ambient `ErrorOptions`, which only exists in the ES2022 lib. The package compiles with `lib: ["ES2022"]` and `skipLibCheck: true`, so this is invisible in-repo, but the name is emitted verbatim into orchestration-already-exists-error.d.ts. Any consumer compiling against an older lib without skipLibCheck then fails with: orchestration-already-exists-error.d.ts(3,44): error TS2304: Cannot find name 'ErrorOptions'. That is exactly what the Functions Host E2E test-app hits: like a real `func` generated app it uses `target: es6` and no skipLibCheck, so its build broke and took the E2E job down with it. Spell the options type structurally as `{ cause?: unknown }` instead. It is structurally identical to `ErrorOptions`, so the runtime behavior and the public API shape are unchanged (all call sites pass either nothing or `{ cause: e }`), but the emitted .d.ts no longer depends on the consumer's lib level. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: bf7b2411-2e06-4e26-be6e-7644d9d09161 --- .../exception/orchestration-already-exists-error.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/durabletask-js/src/orchestration/exception/orchestration-already-exists-error.ts b/packages/durabletask-js/src/orchestration/exception/orchestration-already-exists-error.ts index c50f22fc..a0e2eda6 100644 --- a/packages/durabletask-js/src/orchestration/exception/orchestration-already-exists-error.ts +++ b/packages/durabletask-js/src/orchestration/exception/orchestration-already-exists-error.ts @@ -3,7 +3,11 @@ /** Thrown when an orchestration ID reuse request matches an existing dedupe status. */ export class OrchestrationAlreadyExistsError extends Error { - constructor(message: string, options?: ErrorOptions) { + // The options type is spelled out structurally instead of using the ambient + // `ErrorOptions`, which only exists in the ES2022 lib. Naming it here would leak + // into the emitted .d.ts and break consumers compiling against an older lib + // (e.g. the `func`-style apps that default to `target: es6` without skipLibCheck). + constructor(message: string, options?: { cause?: unknown }) { super(message, options); this.name = "OrchestrationAlreadyExistsError"; }