Skip to content
Merged
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/lucky-pandas-listen.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@workflow/world-vercel': patch
---

Authenticate `deploymentId: "latest"` with the deployment's own OIDC identity instead of an ambient `VERCEL_TOKEN`, and scope the request to the configured team, fixing spurious 404s when resolving the latest deployment
5 changes: 5 additions & 0 deletions .changeset/project-step-provenance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@workflow/web-shared': patch
---

Allow trace callers to add product-specific attributes to event-derived step spans.
12 changes: 12 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 @@ -104,6 +104,18 @@ The `deploymentId` option is currently a Vercel-specific feature. Other Worlds m
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="info">
Resolving `"latest"` is the one `start()` path that calls the Vercel API, so it
needs an identity that can see the calling deployment. Inside a Vercel
deployment the SDK authenticates with the deployment's own OIDC token, which
carries the owning team, and this takes precedence over a `VERCEL_TOKEN` set in
the function's environment. A `VERCEL_TOKEN` belongs to a *user* and carries no
team, so authenticating with it scopes the lookup to that user's default team
and fails with a 404 whenever that is not the team that owns the deployment.
Outside a deployment (CLI, CI, the dashboard) `VERCEL_TOKEN` is still used;
configure the World's `teamId` so the request is scoped explicitly.
</Callout>

<Callout type="warn">
When using `deploymentId: "latest"`, the workflow run will execute on a potentially different deployment than the one calling `start()`. Be mindful of forward and backward compatibility:

Expand Down
12 changes: 12 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 @@ -108,6 +108,18 @@ The `deploymentId` option is currently a Vercel-specific feature. Other Worlds m
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="info">
Resolving `"latest"` is the one `start()` path that calls the Vercel API, so it
needs an identity that can see the calling deployment. Inside a Vercel
deployment the SDK authenticates with the deployment's own OIDC token, which
carries the owning team, and this takes precedence over a `VERCEL_TOKEN` set in
the function's environment. A `VERCEL_TOKEN` belongs to a *user* and carries no
team, so authenticating with it scopes the lookup to that user's default team
and fails with a 404 whenever that is not the team that owns the deployment.
Outside a deployment (CLI, CI, the dashboard) `VERCEL_TOKEN` is still used;
configure the World's `teamId` so the request is scoped explicitly.
</Callout>

<Callout type="warn">
When using `deploymentId: "latest"`, the workflow run will execute on a potentially different deployment than the one calling `start()`. Be mindful of forward and backward compatibility:

Expand Down
7 changes: 6 additions & 1 deletion packages/web-shared/src/components/trace-viewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
} from './sidebar/sidebar-data-context';
import { TraceViewerSkeleton } from './trace-viewer/components/trace-viewer-skeleton';
import { TraceViewer as TraceViewerComponent } from './trace-viewer/trace-viewer';
import type { GetStepAttributes } from './workflow-traces/trace-span-construction';

const TraceViewer = ({
run,
Expand All @@ -17,6 +18,7 @@ const TraceViewer = ({
hasMore,
isLoadingMore,
loading = false,
getStepAttributes,
}: {
run: WorkflowRun;
events: Event[];
Expand All @@ -25,6 +27,8 @@ const TraceViewer = ({
hasMore?: boolean;
isLoadingMore?: boolean;
loading?: boolean;
/** Adds product-specific attributes to event-derived step span data. */
getStepAttributes?: GetStepAttributes;
}) => {
const trace: TraceWithMeta | undefined = useMemo(() => {
if (!run?.runId) {
Expand All @@ -35,9 +39,10 @@ const TraceViewer = ({
// repeats with the whole log in hand.
return buildTrace(run, events, new Date(), {
isCompleteHistory: !hasMore,
getStepAttributes,
});
// eslint-disable-next-line react-hooks/exhaustive-deps -- `new Date()` is intentionally not a dep
}, [run, events, hasMore]);
}, [run, events, hasMore, getStepAttributes]);

// The sidebar shows one entity's slice of the log, so it takes the trace's
// answer rather than recomputing one from the slice.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,10 @@ export function waitToSpan(
};
}

export type GetStepAttributes = (
events: Event[]
) => Record<string, unknown> | undefined;

export const stepEventsToStepEntity = (
events: Event[]
): {
Expand Down Expand Up @@ -231,7 +235,11 @@ export const stepEventsToStepEntity = (
/**
* Converts step events to an OpenTelemetry Span
*/
export function stepToSpan(stepEvents: Event[], maxEndTime: Date): Span | null {
export function stepToSpan(
stepEvents: Event[],
maxEndTime: Date,
getStepAttributes?: GetStepAttributes
): Span | null {
const step = stepEventsToStepEntity(stepEvents);
if (!step) {
return null;
Expand All @@ -242,7 +250,11 @@ export function stepToSpan(stepEvents: Event[], maxEndTime: Date): Span | null {

const attributes = {
resource: 'step' as const,
data: step,
data: {
...getStepAttributes?.(stepEvents),
// Canonical event-derived fields cannot be overridden by extensions.
...step,
},
};

const resource = 'step';
Expand Down
43 changes: 42 additions & 1 deletion packages/web-shared/src/lib/trace-builder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,11 @@ let nextId = 0;

function event(
eventType: EventType,
options: { correlationId?: string; at: number }
options: {
correlationId?: string;
at: number;
externalAttemptId?: string;
}
): Event {
nextId += 1;
return {
Expand All @@ -20,6 +24,7 @@ function event(
createdAt: new Date(BASE_TIME + options.at * 1000),
occurredAt: new Date(BASE_TIME + options.at * 1000),
eventData: eventType === 'step_created' ? { stepName: 'doWork' } : {},
externalAttemptId: options.externalAttemptId,
} as unknown as Event;
}

Expand All @@ -31,6 +36,42 @@ const run = {
} as unknown as WorkflowRun;

describe('buildTrace', () => {
it('adds caller-derived attributes to step span data', () => {
const events = [
event('run_created', { at: 0 }),
event('run_started', { at: 0 }),
event('step_created', { correlationId: 'step_a', at: 1 }),
event('step_started', {
correlationId: 'step_a',
at: 2,
externalAttemptId: 'attempt_first',
}),
event('step_retrying', { correlationId: 'step_a', at: 3 }),
event('step_started', {
correlationId: 'step_a',
at: 4,
externalAttemptId: 'attempt_latest',
}),
];

const trace = buildTrace(run, events, new Date(BASE_TIME + 5000), {
getStepAttributes(stepEvents) {
const latestStart = stepEvents
.slice()
.reverse()
.find((candidate) => candidate.eventType === 'step_started') as
| (Event & { externalAttemptId?: string })
| undefined;
return { externalAttemptId: latestStart?.externalAttemptId };
},
});
const stepSpan = trace.spans.find((span) => span.resource === 'step');

expect(stepSpan?.attributes.data).toMatchObject({
externalAttemptId: 'attempt_latest',
});
});

it('ends a step span on the terminal event the run acted on', () => {
const events = [
event('run_created', { at: 0 }),
Expand Down
17 changes: 13 additions & 4 deletions packages/web-shared/src/lib/trace-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
type WorkflowRun,
} from '@workflow/world';
import {
type GetStepAttributes,
getEventTimestamp,
hookToSpan,
runToSpan,
Expand Down Expand Up @@ -137,7 +138,8 @@ function buildSpans(
run: WorkflowRun,
groupedEvents: GroupedEvents,
now: Date,
latestKnownTime: Date
latestKnownTime: Date,
getStepAttributes?: GetStepAttributes
) {
// Active child spans cap at latestKnownTime so they don't extend into
// unknown territory. Even when the run is completed, we may not have loaded
Expand All @@ -146,7 +148,7 @@ function buildSpans(
const runMaxEnd = run.completedAt ?? now;

const stepSpans = Array.from(groupedEvents.eventsByStepId.values())
.map((events) => stepToSpan(events, childMaxEnd))
.map((events) => stepToSpan(events, childMaxEnd, getStepAttributes))
.filter((span): span is Span => span !== null);

const hookSpans = Array.from(groupedEvents.hookEvents.values())
Expand Down Expand Up @@ -208,7 +210,13 @@ export function buildTrace(
* from the only copy the caller was given, and dropping the wrong one moves
* a span. See {@link findDuplicateEventIds}.
*/
{ isCompleteHistory = false }: { isCompleteHistory?: boolean } = {}
{
isCompleteHistory = false,
getStepAttributes,
}: {
isCompleteHistory?: boolean;
getStepAttributes?: GetStepAttributes;
} = {}
): TraceWithMeta {
// Span geometry comes from what the run acted on. A repeat of a class the
// log already records is read past by every replay, and letting one through
Expand All @@ -232,7 +240,8 @@ export function buildTrace(
run,
groupedEvents,
now,
latestKnownTime
latestKnownTime,
getStepAttributes
);
const sortedCascadingSpans = cascadeSpans(runSpan, spans);

Expand Down
123 changes: 123 additions & 0 deletions packages/world-vercel/src/resolve-latest-deployment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,129 @@ describe('createResolveLatestDeploymentId', () => {
expect(headers.get('Authorization')).toBe('Bearer oidc-token-456');
});

it('prefers the OIDC token over VERCEL_TOKEN inside the Vercel runtime', async () => {
// The shape that broke production: a deployed function whose environment
// also carries a VERCEL_TOKEN for unrelated tooling. The token is a user
// credential and carries no team, so authenticating with it scopes the
// lookup to that user's default team and 404s.
process.env.VERCEL = '1';
process.env.VERCEL_TOKEN = 'user-token-wrong-team';

const { getVercelOidcToken } = await import('@vercel/oidc');
vi.mocked(getVercelOidcToken).mockResolvedValueOnce('oidc-token-789');

mockFetch.mockResolvedValueOnce(
new Response(JSON.stringify({ id: 'dpl_latest_from_oidc' }), {
status: 200,
})
);

const result = await createResolveLatestDeploymentId({})();

expect(result).toBe('dpl_latest_from_oidc');
const headers = mockFetch.mock.calls[0][1].headers as Headers;
expect(headers.get('Authorization')).toBe('Bearer oidc-token-789');
});

it('keeps using VERCEL_TOKEN outside the Vercel runtime', async () => {
// CLI and CI callers export VERCEL_TOKEN deliberately and have no
// deployment identity to prefer, so the original order stands there.
delete process.env.VERCEL;
process.env.VERCEL_TOKEN = 'env-token-123';

const { getVercelOidcToken } = await import('@vercel/oidc');
vi.mocked(getVercelOidcToken).mockResolvedValueOnce('oidc-token-789');

mockFetch.mockResolvedValueOnce(
new Response(JSON.stringify({ id: 'dpl_from_env' }), { status: 200 })
);

await createResolveLatestDeploymentId({})();

const headers = mockFetch.mock.calls[0][1].headers as Headers;
expect(headers.get('Authorization')).toBe('Bearer env-token-123');
});

it('still prefers an explicit config token inside the Vercel runtime', async () => {
process.env.VERCEL = '1';
process.env.VERCEL_TOKEN = 'env-token-123';

// Queueing an OIDC value here would leak into the next test, since an
// explicit config token short-circuits before OIDC is ever consulted.
// Assert that directly instead.
const { getVercelOidcToken } = await import('@vercel/oidc');
vi.mocked(getVercelOidcToken).mockClear();

mockFetch.mockResolvedValueOnce(
new Response(JSON.stringify({ id: 'dpl_from_config' }), { status: 200 })
);

await createResolveLatestDeploymentId({ token: 'config-token' })();

const headers = mockFetch.mock.calls[0][1].headers as Headers;
expect(headers.get('Authorization')).toBe('Bearer config-token');
expect(getVercelOidcToken).not.toHaveBeenCalled();
});

it('falls back to VERCEL_TOKEN inside the runtime when OIDC is unavailable', async () => {
process.env.VERCEL = '1';
process.env.VERCEL_TOKEN = 'env-token-123';

const { getVercelOidcToken } = await import('@vercel/oidc');
// mockReset clears any queue a prior test left behind; the rejection is
// scoped to this call so it does not leak into later tests.
vi.mocked(getVercelOidcToken).mockReset();
vi.mocked(getVercelOidcToken).mockRejectedValueOnce(new Error('no OIDC'));

mockFetch.mockResolvedValueOnce(
new Response(JSON.stringify({ id: 'dpl_from_env' }), { status: 200 })
);

await createResolveLatestDeploymentId({})();

const headers = mockFetch.mock.calls[0][1].headers as Headers;
expect(headers.get('Authorization')).toBe('Bearer env-token-123');
});

it('scopes the request to the configured team', async () => {
mockFetch.mockResolvedValueOnce(
new Response(JSON.stringify({ id: 'dpl_latest' }), { status: 200 })
);

await createResolveLatestDeploymentId({
token: 'test-token',
projectConfig: { projectId: 'prj_123', teamId: 'team_abc' },
})();

const url = new URL(mockFetch.mock.calls[0][0] as string);
expect(url.pathname).toBe(
'/v1/workflow/resolve-latest-deployment/dpl_current_abc123'
);
expect(url.searchParams.get('teamId')).toBe('team_abc');
});

it('omits the team parameter when no team is configured', async () => {
mockFetch.mockResolvedValueOnce(
new Response(JSON.stringify({ id: 'dpl_latest' }), { status: 200 })
);

await createResolveLatestDeploymentId({ token: 'test-token' })();

expect(mockFetch.mock.calls[0][0]).toBe(
'https://api.vercel.com/v1/workflow/resolve-latest-deployment/dpl_current_abc123'
);
});

it('explains the identity cause on a 404', async () => {
mockFetch.mockResolvedValueOnce(
new Response('Deployment not found.', { status: 404 })
);

await expect(
createResolveLatestDeploymentId({ token: 'test-token' })()
).rejects.toThrow(/not visible to the identity/);
});

it('should throw on non-ok HTTP response', async () => {
mockFetch.mockResolvedValueOnce(new Response('Not found', { status: 404 }));

Expand Down
Loading
Loading