Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/deployment-id-latest-noop-non-vercel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@workflow/core': patch
---

`start({ deploymentId: 'latest' })` is now a no-op in Worlds that don't support atomic deployments (local dev, Postgres) instead of throwing — it logs a warning and targets the current deployment, so workflows that use `'latest'` on Vercel still run locally.
2 changes: 2 additions & 0 deletions docs/content/docs/v4/api-reference/workflow-api/start.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,8 @@ const run = await start(myWorkflow, ["arg1", "arg2"], { // [!code highlight]

<Callout type="info">
The `deploymentId` option is currently a Vercel-specific feature. Other Worlds may implement this option differently to match their own deployment runtimes, and the World spec may rename it from `deploymentId` to `version` in a future SDK version. On Vercel, `"latest"` resolves to the most recent deployment matching your current environment — the same production target for production deployments, or the same git branch for preview deployments.

In Worlds without atomic, immutable deployments (such as local development or self-hosted Postgres), there is no notion of multiple deployments to resolve between, so `deploymentId: "latest"` has no effect: the SDK logs a warning and the run targets the current deployment. This means a workflow that opts into `"latest"` on Vercel still runs unchanged in local development.
</Callout>

<Callout type="warn">
Expand Down
2 changes: 2 additions & 0 deletions docs/content/docs/v5/api-reference/workflow-api/start.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,8 @@ const run = await start(myWorkflow, ["arg1", "arg2"], { // [!code highlight]

<Callout type="info">
The `deploymentId` option is currently a Vercel-specific feature. Other Worlds may implement this option differently to match their own deployment runtimes, and the World spec may rename it from `deploymentId` to `version` in a future SDK version. On Vercel, `"latest"` resolves to the most recent deployment matching your current environment — the same production target for production deployments, or the same git branch for preview deployments.

In Worlds without atomic, immutable deployments (such as local development or self-hosted Postgres), there is no notion of multiple deployments to resolve between, so `deploymentId: "latest"` has no effect: the SDK logs a warning and the run targets the current deployment. This means a workflow that opts into `"latest"` on Vercel still runs unchanged in local development.
</Callout>

<Callout type="warn">
Expand Down
29 changes: 29 additions & 0 deletions packages/core/e2e/e2e.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
import fs from 'node:fs';
import path from 'node:path';
import { setTimeout as sleep } from 'node:timers/promises';
Expand Down Expand Up @@ -245,6 +245,35 @@
);
});

// `deploymentId: 'latest'` only resolves to a different deployment in
// worlds with atomic, immutable deployments (Vercel). In local dev and
// Postgres worlds there is nothing to resolve between, so it has no effect:
// the SDK logs a warning and targets the current deployment instead of
// failing. This guards that no-op, so a workflow that opts into
// `deploymentId: 'latest'` on Vercel still runs in local/Postgres dev. The
// warning itself is asserted in start.test.ts. Skipped on Vercel, where
// 'latest' actually hits the resolve API.
test.skipIf(!!process.env.WORKFLOW_VERCEL_ENV)(
"deploymentId: 'latest' is a no-op in non-Vercel worlds",
{ timeout: 60_000 },
async () => {
const run = await start(await e2e('addTenWorkflow'), [123], {
deploymentId: 'latest',
});

const returnValue = await run.returnValue;
expect(returnValue).toBe(133);

const { json } = await cliInspectJson(`runs ${run.runId} --withData`);
expect(json).toMatchObject({
runId: run.runId,
status: 'completed',
input: [123],
output: 133,
});
}
);

// Test that "use step" / "use workflow" functions inside dot-prefixed
// directories like `.well-known/agent/` are discovered and executed correctly.
// Only runs on Next.js workbenches where the test file is placed.
Expand Down
76 changes: 67 additions & 9 deletions packages/core/src/runtime/start.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,10 @@ import {
it,
vi,
} from 'vitest';
import { runtimeLogger } from '../logger.js';
import type { Run } from './run.js';
import type { WorkflowFunction } from './start.js';
import { start } from './start.js';
import { _resetLatestNoOpWarnForTests, start } from './start.js';
import { setWorld } from './world.js';

// Mock @vercel/functions
Expand Down Expand Up @@ -440,11 +441,17 @@ describe('start', () => {
});
});
mockQueue = vi.fn().mockResolvedValue(undefined);
// Reset the warn-once guard so the no-op warn path is exercisable
// regardless of test order.
_resetLatestNoOpWarnForTests();
});

afterEach(() => {
setWorld(undefined);
vi.clearAllMocks();
// Restore any spies (e.g. on runtimeLogger.warn) even if a test threw
// before its own cleanup — clearAllMocks alone doesn't restore spies.
vi.restoreAllMocks();
});

it('should resolve "latest" to the actual deployment ID via resolveLatestDeploymentId', async () => {
Expand Down Expand Up @@ -514,23 +521,74 @@ describe('start', () => {
);
});

it('should throw WorkflowRuntimeError when "latest" is used with a World that does not implement resolveLatestDeploymentId', async () => {
it('should warn and fall back to the current deployment ID when "latest" is used with a World that does not implement resolveLatestDeploymentId', async () => {
const warnSpy = vi
.spyOn(runtimeLogger, 'warn')
.mockImplementation(() => {});

Comment on lines +524 to +528

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 17604c2. The block's afterEach now calls vi.restoreAllMocks() (in addition to vi.clearAllMocks()), so the runtimeLogger.warn spy is restored even if an assertion throws before the test's own cleanup. Dropped the manual warnSpy.mockRestore(), and the guard is reset in beforeEach so the warn path is exercisable regardless of test order.

setWorld({
getDeploymentId: vi.fn().mockResolvedValue('deploy_123'),
events: { create: mockEventsCreate },
queue: mockQueue,
// No resolveLatestDeploymentId
} as any);

await expect(
start(validWorkflow, [], { deploymentId: 'latest' })
).rejects.toThrow(WorkflowRuntimeError);
// Should not throw — 'latest' is a no-op in worlds without atomic
// deployments.
await start(validWorkflow, [], { deploymentId: 'latest' });

await expect(
start(validWorkflow, [], { deploymentId: 'latest' })
).rejects.toThrow(
"deploymentId 'latest' requires a World that implements resolveLatestDeploymentId()"
// It should warn that 'latest' had no effect in this world.
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("deploymentId: 'latest' has no effect"),
expect.objectContaining({ currentDeploymentId: 'deploy_123' })
);

// The run should fall back to the current deployment ID in both the
// run_created event and the queue call.
expect(mockEventsCreate).toHaveBeenCalledWith(
expect.stringMatching(/^wrun_/),
expect.objectContaining({
eventType: 'run_created',
eventData: expect.objectContaining({
deploymentId: 'deploy_123',
}),
}),
expect.anything()
);
expect(mockQueue).toHaveBeenCalledWith(
expect.any(String),
expect.any(Object),
expect.objectContaining({ deploymentId: 'deploy_123' })
);
});

it('should only warn once per process when "latest" is used repeatedly in an unsupported World', async () => {
const warnSpy = vi
.spyOn(runtimeLogger, 'warn')
.mockImplementation(() => {});

setWorld({
getDeploymentId: vi.fn().mockResolvedValue('deploy_123'),
events: { create: mockEventsCreate },
queue: mockQueue,
// No resolveLatestDeploymentId
} as any);

// Multiple runs that all hit the no-op path...
await start(validWorkflow, [], { deploymentId: 'latest' });
await start(validWorkflow, [], { deploymentId: 'latest' });
await start(validWorkflow, [], { deploymentId: 'latest' });

// ...should only log the warning a single time.
expect(warnSpy).toHaveBeenCalledTimes(1);

// ...but every run still falls back to the current deployment.
expect(mockQueue).toHaveBeenCalledTimes(3);
for (const call of mockQueue.mock.calls) {
expect(call[2]).toEqual(
expect.objectContaining({ deploymentId: 'deploy_123' })
);
}
});

it('should not call resolveLatestDeploymentId when a normal deploymentId is provided', async () => {
Expand Down
47 changes: 41 additions & 6 deletions packages/core/src/runtime/start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,22 @@ const CROSS_DEPLOYMENT_CAPABILITY_PROBE_TIMEOUT_MS = 2_000;
/** ULID generator for client-side runId generation */
const ulid = monotonicFactory();

// `deploymentId: 'latest'` is a no-op in Worlds without atomic deployments.
// The warning that explains this only needs to fire once per process: a
// workflow that hardcodes 'latest' for its Vercel deployment would otherwise
// log it on every local/Postgres run, flooding tight dev loops.
let hasWarnedLatestNoOp = false;

/**
* Reset the `deploymentId: 'latest'` no-op warn-once guard. Test-only —
* exported so unit tests can exercise the warn path across `start()` calls.
*
* @internal
*/
export function _resetLatestNoOpWarnForTests(): void {
hasWarnedLatestNoOp = false;
}

export interface StartOptionsBase {
/**
* The world to use for the workflow run creation,
Expand Down Expand Up @@ -83,7 +99,10 @@ export interface StartOptionsWithDeploymentId extends StartOptionsBase {
*
* Set to `'latest'` to automatically resolve the most recent deployment
* for the current environment (same production target or git branch).
* This is currently a Vercel-specific feature.
* This is only meaningful in worlds with atomic, immutable deployments
* (currently Vercel). In other worlds (local dev, Postgres) there is no
* notion of multiple deployments to resolve between, so `'latest'` has no
* effect — a warning is logged and the run targets the current deployment.
*
* **Note:** When `deploymentId` is provided, the argument and return types become `unknown`
* since there is no guarantee the types will be consistent across deployments.
Expand Down Expand Up @@ -190,13 +209,29 @@ export async function start<TArgs extends unknown[], TResult>(
// When 'latest' is requested, resolve the actual latest deployment ID
// for the current deployment's environment (same production target or
// same git branch for preview deployments).
//
// Resolving 'latest' only means something in worlds with atomic,
// immutable deployments (e.g. Vercel), which implement
// resolveLatestDeploymentId(). Worlds without that concept (local dev,
// self-hosted Postgres) have nothing to resolve between, so rather than
// fail a run that works fine on Vercel, we warn and fall back to the
// current deployment — making 'latest' an effective no-op there.
if (deploymentId === 'latest') {
if (!world.resolveLatestDeploymentId) {
throw new WorkflowRuntimeError(
"deploymentId 'latest' requires a World that implements resolveLatestDeploymentId()"
);
if (world.resolveLatestDeploymentId) {
deploymentId = await world.resolveLatestDeploymentId();
} else {
// Warn once per process — see hasWarnedLatestNoOp above.
if (!hasWarnedLatestNoOp) {
hasWarnedLatestNoOp = true;
runtimeLogger.warn(
"deploymentId: 'latest' has no effect in this world and was ignored. " +
'It is only supported by worlds with atomic deployments, such as Vercel. ' +
'The run will target the current deployment.',
{ currentDeploymentId }
);
}
deploymentId = currentDeploymentId;
}
deploymentId = await world.resolveLatestDeploymentId();
}

// Decide whether to write byte streams in the framed wire format.
Expand Down
Loading